feat(setup): add Kimi Code client support (#2744)

* feat(setup): add Kimi Code client support

Registers rea in ~/.kimi-code/mcp.json (or $KIMI_CODE_HOME/mcp.json), preserving unrelated settings with a .rea.backup; registration is command-based stdio with no type field, startupTimeoutMs 30000 and toolTimeoutMs 120000 from MCP_STARTUP_POLICY.
Strict JSON parsing: empty or whitespace-only files initialize; malformed JSON is rejected untouched with no backup; explicit transport other than stdio reports invalid.
Bundled skill uses the shared ~/.agents/skills destination that Kimi Code discovers, installed and verified through the shared-skill flow.
Validated by 19 Kimi-specific tests, 260 adjacent-client regressions passing, check:fast and docs:check, and real-client verification on Kimi Code 0.40.1 (173 tools discovered, real mcp__rea__analyze_javascript_application call succeeded).

* fix(setup): preserve Kimi tool filters and strict JSON coverage

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Tony Yu 2026-10-13 23:36:54 +08:00 committed by GitHub
parent b19f9e4d2d
commit efc491ab45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 468 additions and 12 deletions

View File

@ -129,6 +129,18 @@ and Claude Code reports that autocompact is thrashing. To avoid this:
The sizes above come from REA's own `tools/list`. A live Claude Code session
against DeepSeek's API is unverified.
## Kimi Code
Setup registers `rea` in `~/.kimi-code/mcp.json`, or
`$KIMI_CODE_HOME/mcp.json` when set, while preserving unrelated settings and
creating a `.rea.backup` before updating an existing file. Kimi Code infers
stdio from the `command` entry, so setup does not add a `type` field. The
registration includes `startupTimeoutMs = 30000` and `toolTimeoutMs = 120000`.
Kimi Code also discovers personal skills in `~/.agents/skills` and project-local
`.agents/skills`, so no separate skill root is required; setup still installs
and verifies the matching skill files through the normal shared-skill flow.
Start a new Kimi Code session to pick up MCP and skill changes.
## Pi
For [Pi](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/mcp.md),

View File

@ -279,6 +279,7 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| GitHub Copilot CLI | `copilot_cli` |
| Command Code | `commandcode` |
| Qwen Code | `qwen_code` |
| Kimi Code | `kimi` |
| VS Code | `vscode` |
| Grok Build | `grok_build` |
| OMP | `omp` |
@ -290,8 +291,8 @@ hosted connector; see [its configuration](client-configuration.md#other-client-s
| ZCode | `zcode` |
| DeepSeek Harness | `dsh` |
`dsh` is main-only until a release after 6.4.0. The registry check above does
not cover it.
`dsh` and `kimi` require a release containing them. The registry check above does
not cover them.
See [client configuration and troubleshooting](client-configuration.md) for
configuration paths, profiles, trust policies, and schema-limit workarounds.

View File

@ -110,9 +110,12 @@ const parseDocument = (
return requireObject(parseToml(text), "The configuration root");
// Pi loads `mcp.json` with a strict `JSON.parse` and no BOM tolerance, so
// comments, trailing commas, a BOM, or an empty existing file must be
// rejected instead of registering into a document Pi cannot load.
if (format === "pi")
// rejected instead of registering into a document the client cannot load.
// Kimi also reads mcp.json with JSON.parse and an empty-file fallback.
if (format === "pi" || format === "kimi") {
if (format === "kimi" && text.trim().length === 0) return {};
return requireObject(JSON.parse(text), "The configuration root");
}
// An empty file, which some clients create before any server is added,
// holds no settings to preserve.
if (isEmptyClientConfigurationText(text)) return {};
@ -1446,6 +1449,13 @@ export const clientRegistrationEntry = (
? {}
: { environment: { ...environment } }),
};
case "kimi":
// Kimi infers stdio from command; it uses `transport`, not `type`.
return {
command: executable,
args,
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
};
case "opencode_v2":
// V2 connects servers unless `disabled` is true.
return {

View File

@ -66,6 +66,8 @@ const registrationSchema = z
command: z.string().min(1),
args: z.array(z.string()).default([]),
startup_timeout_sec: z.number().positive().optional(),
startupTimeoutMs: z.number().positive().optional(),
toolTimeoutMs: z.number().positive().optional(),
type: z.string().optional(),
transport: z.string().optional(),
serverName: z.string().optional(),
@ -258,6 +260,21 @@ const registrationAligned = (
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
)
return false;
if (
client.name === "kimi" &&
(registration.startupTimeoutMs !==
MCP_STARTUP_POLICY.kimiStartupTimeoutMs ||
registration.toolTimeoutMs !== MCP_STARTUP_POLICY.kimiToolTimeoutMs)
)
return false;
if (
(client.format === "kimi" ||
client.format === "omp" ||
client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)
return false;
if (client.format === "vscode" && registration.type !== "stdio") return false;
// A Harness registration only connects as the rea stdio server; another
// server name or transport leaves the session without REA tools.
@ -266,13 +283,6 @@ const registrationAligned = (
(registration.serverName !== "rea" || registration.transport !== "stdio")
)
return false;
// OMP and Pi infer stdio for a command entry without an explicit type.
if (
(client.format === "omp" || client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)
return false;
if (
client.format === "copilot_cli" &&
(registration.type !== "stdio" ||
@ -331,6 +341,12 @@ const parseRegistration = (
});
}
const registration = registrationSchema.parse(value);
if (
client.format === "kimi" &&
registration.transport !== undefined &&
registration.transport !== "stdio"
)
throw new TypeError("Expected an stdio registration");
if (
(client.format === "vscode" || client.format === "copilot_cli") &&
registration.type !== "stdio"
@ -339,7 +355,9 @@ const parseRegistration = (
if (client.format === "commandcode" && registration.transport !== "stdio")
throw new TypeError("Expected an stdio registration");
if (
(client.format === "omp" || client.format === "pi") &&
(client.format === "kimi" ||
client.format === "omp" ||
client.format === "pi") &&
registration.type !== undefined &&
registration.type !== "stdio"
)

View File

@ -704,6 +704,14 @@ const clientConfigurationDesired = (
launcher,
environment.value,
);
const previous = parsed?.servers[PRODUCT_IDENTITY.mcpServerKey];
if (
client.name === "kimi" &&
typeof previous === "object" &&
previous !== null &&
"enabledTools" in previous
)
registration.enabledTools = previous.enabledTools;
return {
...registration,
...(client.name === "codex" || client.name === "grok_build"
@ -711,5 +719,11 @@ const clientConfigurationDesired = (
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
}
: {}),
...(client.name === "kimi"
? {
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
}
: {}),
};
};

View File

@ -45,6 +45,7 @@ export interface SetupClient {
| "grok"
| "omp"
| "pi"
| "kimi"
| "hermes"
| "zcode"
| "dsh"
@ -165,6 +166,10 @@ const claudeCodeMarkerDirectory = ({ home, env }: ClientPathContext): string =>
const codexDirectory = ({ home, env }: ClientPathContext): string =>
env.CODEX_HOME || join(home, ".codex");
/** Kimi Code stores MCP declarations beside the rest of its home data. */
const kimiDirectory = ({ home, env }: ClientPathContext): string =>
env.KIMI_CODE_HOME || join(home, ".kimi-code");
const qoderDirectory = ({ home, env }: ClientPathContext): string =>
env.QODER_CONFIG_DIR || join(home, ".qoder");
@ -576,6 +581,14 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
markerPath: qwenCodeDirectory,
format: "json",
},
{
name: "kimi",
displayName: "Kimi Code",
configPath: (context: ClientPathContext) =>
join(kimiDirectory(context), "mcp.json"),
markerPath: kimiDirectory,
format: "kimi",
},
{
name: "vscode",
displayName: "VS Code",

View File

@ -7,6 +7,8 @@
*/
export const MCP_STARTUP_POLICY = {
codexStartupTimeoutSeconds: 30,
kimiStartupTimeoutMs: 30_000,
kimiToolTimeoutMs: 120_000,
initializeBudgetMs: 15_000,
toolsListBudgetMs: 5_000,
firstCatalogBudgetMs: 20_000,

View File

@ -64,6 +64,7 @@ describe("client configuration filesystem lifecycle", () => {
"copilot_cli",
"commandcode",
"qwen_code",
"kimi",
"vscode",
"trae",
"grok_build",

View File

@ -0,0 +1,384 @@
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js";
import { setupRegistrationCommand } from "../../../src/application/SetupHost.js";
import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js";
import {
clientSkillDirectories,
supportedClients,
} from "../../../src/application/SupportedClients.js";
import { systemUninstallHost } from "../../../src/application/Uninstall.js";
import { MCP_STARTUP_POLICY } from "../../../src/mcpStartupPolicy.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
clearClientLocationEnvironment,
NPX_REGISTRATION_COMMAND,
} from "../../fixtures/clientEnvironment.js";
const command = NPX_REGISTRATION_COMMAND;
beforeEach(clearClientLocationEnvironment);
afterEach(() => vi.unstubAllEnvs());
const kimiPath = (
home: string,
platform: NodeJS.Platform,
env: Parameters<typeof supportedClients>[2] = {},
) => supportedClients(home, platform, env).find(({ name }) => name === "kimi");
const exists = async (path: string): Promise<boolean> => {
try {
await access(path);
return true;
} catch {
return false;
}
};
const expectedRegistration = (launcher: readonly string[]) => ({
command: launcher[0],
args: launcher.slice(1),
startupTimeoutMs: MCP_STARTUP_POLICY.kimiStartupTimeoutMs,
toolTimeoutMs: MCP_STARTUP_POLICY.kimiToolTimeoutMs,
});
describe("Kimi Code configuration paths and skills", () => {
it.each([
[undefined, join("/home/a/.kimi-code")],
["", join("/home/a/.kimi-code")],
[join("/tmp", "isolated-kimi"), join("/tmp", "isolated-kimi")],
])("resolves KIMI_CODE_HOME for %j", (override, directory) => {
const client = kimiPath(
"/home/a",
"linux",
override === undefined ? {} : { KIMI_CODE_HOME: override },
);
expect(client).toEqual({
name: "kimi",
displayName: "Kimi Code",
configPath: join(directory, "mcp.json"),
markerPath: directory,
format: "kimi",
});
});
it("uses the existing shared skill destination without a separate skill root", () => {
expect(clientSkillDirectories("/home/a", ["kimi"])).toEqual([
{ client: "shared", directory: "/home/a/.agents/skills" },
]);
});
});
describe("Kimi Code registration lifecycle", () => {
const kimiClient = async () => {
const home = await createTestTempDirectory("rea-kimi-");
const client = kimiPath(home, process.platform, {});
if (client?.markerPath === undefined)
throw new Error("missing Kimi Code client");
await mkdir(client.markerPath, { recursive: true });
return { home, client };
};
it("preserves the caller's enabledTools selection during refresh", async () => {
const { client } = await kimiClient();
const enabledTools = ["binary_session", "analyze_javascript_application"];
await writeFile(
client.configPath,
JSON.stringify({
mcpServers: {
rea: { command: "old-rea", args: ["mcp"], enabledTools },
},
}),
);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
expect(
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
).toEqual({
...expectedRegistration(command),
enabledTools,
});
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("unchanged");
});
it("registers without type, preserves unrelated settings, backs up, and is idempotent", async () => {
const { home, client } = await kimiClient();
const original = `{
"customSetting": { "enabled": true },
"mcpServers": {
"other": { "command": "other-server", "startupTimeoutMs": 5000 }
}
}
`;
await writeFile(client.configPath, original);
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "configured",
backupPath: `${client.configPath}.rea.backup`,
});
const text = await readFile(client.configPath, "utf8");
expect(JSON.parse(text)).toEqual({
customSetting: { enabled: true },
mcpServers: {
other: { command: "other-server", startupTimeoutMs: 5000 },
rea: expectedRegistration(command),
},
});
expect(text).not.toContain('"type"');
expect(text).not.toContain('"transport"');
expect(await readFile(`${client.configPath}.rea.backup`, "utf8")).toBe(
original,
);
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "unchanged",
});
expect(await readFile(client.configPath, "utf8")).toBe(text);
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "aligned", command });
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
"removed",
);
expect(JSON.parse(await readFile(client.configPath, "utf8"))).toEqual({
customSetting: { enabled: true },
mcpServers: {
other: { command: "other-server", startupTimeoutMs: 5000 },
},
});
});
});
describe("Kimi Code package launchers", () => {
const kimiClient = async () => {
const home = await createTestTempDirectory("rea-kimi-launcher-");
const client = kimiPath(home, process.platform, {});
if (client?.markerPath === undefined)
throw new Error("missing Kimi Code client");
await mkdir(client.markerPath, { recursive: true });
return { home, client } as const;
};
it.each([
["npx", "linux"],
["bunx", "linux"],
["npx", "win32"],
["bunx", "win32"],
] as const)(
"keeps the %s launcher aligned on %s",
async (runner, platform) => {
const { home, client } = await kimiClient();
const launcher = setupRegistrationCommand(platform, runner);
expect(
(await configureClientConfiguration(client, {}, launcher)).status,
).toBe("configured");
expect(
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
).toEqual(expectedRegistration(launcher));
expect(
(
await readClientRegistrationStatuses(home, undefined, {
platform,
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "aligned", command: launcher });
expect(await configureClientConfiguration(client, {}, launcher)).toEqual({
status: "unchanged",
});
},
);
});
describe("Kimi Code transport validation", () => {
const kimiClient = async () => {
const home = await createTestTempDirectory("rea-kimi-validation-");
const client = kimiPath(home, process.platform, {});
if (client?.markerPath === undefined)
throw new Error("missing Kimi Code client");
await mkdir(client.markerPath, { recursive: true });
return { home, client };
};
it("creates a registration in a zero-byte file and accepts whitespace-only files", async () => {
const { home, client } = await kimiClient();
for (const original of ["", " \n\t"]) {
await writeFile(client.configPath, original);
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
expect(
JSON.parse(await readFile(client.configPath, "utf8")).mcpServers.rea,
).toEqual(expectedRegistration(command));
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "unchanged",
});
expect(
(await systemUninstallHost(home).removeClient(client)).status,
).toBe("removed");
}
});
it.each([
'{"mcpServers": {',
'{/* comment */ "mcpServers": {}}',
'{"mcpServers": {},}',
'\uFEFF{"mcpServers": {}}',
])("rejects invalid strict JSON %j without writes", async (original) => {
const { client } = await kimiClient();
await writeFile(client.configPath, original);
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "failed",
reason: "readback",
});
expect(await readFile(client.configPath, "utf8")).toBe(original);
expect(await exists(`${client.configPath}.rea.backup`)).toBe(false);
});
it("accepts explicit stdio transport without causing repeated rewrites", async () => {
const { home, client } = await kimiClient();
await writeFile(
client.configPath,
JSON.stringify({
mcpServers: {
rea: {
transport: "stdio",
...expectedRegistration(command),
},
},
}),
);
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "aligned", command });
expect(
(await configureClientConfiguration(client, {}, command)).status,
).toBe("configured");
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "unchanged",
});
expect(await configureClientConfiguration(client, {}, command)).toEqual({
status: "unchanged",
});
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "aligned", command });
});
it.each(["sse"])(
"rejects explicit transport %j instead of silently accepting another protocol",
async (transport) => {
const { home, client } = await kimiClient();
await writeFile(
client.configPath,
JSON.stringify({
mcpServers: {
rea: {
transport,
...expectedRegistration(command),
},
},
}),
);
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "invalid", command: [] });
},
);
});
describe("Kimi Code timeout validation", () => {
const kimiClient = async () => {
const home = await createTestTempDirectory("rea-kimi-timeout-");
const client = kimiPath(home, process.platform, {});
if (client?.markerPath === undefined)
throw new Error("missing Kimi Code client");
await mkdir(client.markerPath, { recursive: true });
return { home, client } as const;
};
it.each([
["startupTimeoutMs", 1],
["toolTimeoutMs", 1],
["startupTimeoutMs", undefined],
["toolTimeoutMs", undefined],
] as const)("reports stale when %s is %j", async (field, value) => {
const { home, client } = await kimiClient();
const registration = {
...expectedRegistration(command),
[field]: value,
};
await writeFile(
client.configPath,
JSON.stringify({ mcpServers: { rea: registration } }),
);
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "stale" });
});
it("uses a direct installed REA command without requiring a package runner", async () => {
const { home, client } = await kimiClient();
const entry = join(home, "scripts", "rea.mjs");
const launcher = [process.execPath, entry, "mcp"];
expect(
(await configureClientConfiguration(client, {}, launcher)).status,
).toBe("configured");
expect(
(
await readClientRegistrationStatuses(home, entry, {
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "aligned", command: launcher });
});
it("leaves a foreign rea-key registration out of aligned state", async () => {
const { home, client } = await kimiClient();
await writeFile(
client.configPath,
JSON.stringify({
mcpServers: {
rea: {
...expectedRegistration(command),
command: "another-package",
args: ["mcp"],
},
},
}),
);
expect(
(
await readClientRegistrationStatuses(home, undefined, {
environment: {},
})
).find(({ client: name }) => name === "kimi"),
).toMatchObject({ state: "stale" });
});
});

View File

@ -27,6 +27,7 @@ export const CLIENT_LOCATION_ENVIRONMENT = [
"GEMINI_CLI_SYSTEM_DEFAULTS_PATH",
"HERMES_DATA_DIR_SUFFIX",
"HERMES_HOME",
"KIMI_CODE_HOME",
"LOCALAPPDATA",
"OMP_PROFILE",
"OPENCODE_CONFIG",