From efc491ab4527b9e2084c6519f250472a1322b096 Mon Sep 17 00:00:00 2001 From: Tony Yu <49832190+ysntony@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:36:54 +0800 Subject: [PATCH] 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> --- docs/client-configuration.md | 12 + docs/installation.md | 5 +- .../ClientConfigurationDocument.ts | 14 +- src/application/ClientRegistrationStatus.ts | 34 +- src/application/SetupClientConfiguration.ts | 14 + src/application/SupportedClients.ts | 13 + src/mcpStartupPolicy.ts | 2 + .../filesystem/clientLifecycle.test.ts | 1 + .../kimiClientConfiguration.test.ts | 384 ++++++++++++++++++ tests/fixtures/clientEnvironment.ts | 1 + 10 files changed, 468 insertions(+), 12 deletions(-) create mode 100644 tests/boundary/filesystem/kimiClientConfiguration.test.ts diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 1a85dbdfa..409bc7f19 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -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), diff --git a/docs/installation.md b/docs/installation.md index 8cddafee0..a39471585 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. diff --git a/src/application/ClientConfigurationDocument.ts b/src/application/ClientConfigurationDocument.ts index 1b241a98b..a226f8cb6 100644 --- a/src/application/ClientConfigurationDocument.ts +++ b/src/application/ClientConfigurationDocument.ts @@ -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 { diff --git a/src/application/ClientRegistrationStatus.ts b/src/application/ClientRegistrationStatus.ts index d107c5f32..56a3e81ba 100644 --- a/src/application/ClientRegistrationStatus.ts +++ b/src/application/ClientRegistrationStatus.ts @@ -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" ) diff --git a/src/application/SetupClientConfiguration.ts b/src/application/SetupClientConfiguration.ts index a868c6177..90f4bf4ca 100644 --- a/src/application/SetupClientConfiguration.ts +++ b/src/application/SetupClientConfiguration.ts @@ -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, + } + : {}), }; }; diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 8407da4c0..55a651a52 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -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", diff --git a/src/mcpStartupPolicy.ts b/src/mcpStartupPolicy.ts index 280a328c2..dc31114c1 100644 --- a/src/mcpStartupPolicy.ts +++ b/src/mcpStartupPolicy.ts @@ -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, diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 24f655507..0e4054ae6 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -64,6 +64,7 @@ describe("client configuration filesystem lifecycle", () => { "copilot_cli", "commandcode", "qwen_code", + "kimi", "vscode", "trae", "grok_build", diff --git a/tests/boundary/filesystem/kimiClientConfiguration.test.ts b/tests/boundary/filesystem/kimiClientConfiguration.test.ts new file mode 100644 index 000000000..e0580031a --- /dev/null +++ b/tests/boundary/filesystem/kimiClientConfiguration.test.ts @@ -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[2] = {}, +) => supportedClients(home, platform, env).find(({ name }) => name === "kimi"); + +const exists = async (path: string): Promise => { + 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" }); + }); +}); diff --git a/tests/fixtures/clientEnvironment.ts b/tests/fixtures/clientEnvironment.ts index 9825caeec..5e2ba94c2 100644 --- a/tests/fixtures/clientEnvironment.ts +++ b/tests/fixtures/clientEnvironment.ts @@ -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",