From 8b856ee802c72215d63a30a944f0a9a0f7538174 Mon Sep 17 00:00:00 2001 From: Fnine59 <36078040+Fnine59@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:37:40 +0000 Subject: [PATCH 1/2] fix(acp): support relayed stdio MCP servers --- .changeset/fix-acp-stdio-mcp.md | 5 ++++ packages/acp-server/src/convert.ts | 9 ++++++- packages/acp-server/test/convert.test.ts | 14 +++++++--- packages/acp-server/test/lifecycle.test.ts | 31 +++++++++++++++++----- 4 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-acp-stdio-mcp.md diff --git a/.changeset/fix-acp-stdio-mcp.md b/.changeset/fix-acp-stdio-mcp.md new file mode 100644 index 0000000000..3583d4f6de --- /dev/null +++ b/.changeset/fix-acp-stdio-mcp.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow ACP clients to use relayed stdio MCP servers. diff --git a/packages/acp-server/src/convert.ts b/packages/acp-server/src/convert.ts index 308d400a0d..48c34bff63 100644 --- a/packages/acp-server/src/convert.ts +++ b/packages/acp-server/src/convert.ts @@ -176,7 +176,14 @@ export function acpMcpServersToConfigRecord( const out: Record = {}; for (const server of servers) { if (!('type' in server)) { - throw new Error(`ACP stdio MCP server ${server.name} does not declare a runtime identity`); + out[server.name] = { + transport: 'stdio', + command: server.command, + args: server.args, + env: namedPairsToRecord(server.env), + runtime_id: 'local', + }; + continue; } if (server.type === 'http' || server.type === 'sse') { out[server.name] = { diff --git a/packages/acp-server/test/convert.test.ts b/packages/acp-server/test/convert.test.ts index c5a1c490ca..4e267de645 100644 --- a/packages/acp-server/test/convert.test.ts +++ b/packages/acp-server/test/convert.test.ts @@ -19,7 +19,7 @@ describe('acpMcpServersToConfigRecord', () => { expect(acpMcpServersToConfigRecord([])).toBeUndefined(); }); - it('rejects stdio servers that cannot declare a runtime identity', () => { + it('maps type-absent stdio servers to the local runtime with args and env', () => { const servers: McpServer[] = [ { name: 'fs', @@ -31,9 +31,15 @@ describe('acpMcpServersToConfigRecord', () => { ], }, ]; - expect(() => acpMcpServersToConfigRecord(servers)).toThrow( - 'ACP stdio MCP server fs does not declare a runtime identity', - ); + expect(acpMcpServersToConfigRecord(servers)).toEqual({ + fs: { + transport: 'stdio', + command: '/usr/local/bin/mcp-fs', + args: ['--root', '/tmp'], + env: { API_KEY: 'secret', DEBUG: '1' }, + runtime_id: 'local', + }, + }); }); it('maps http and sse servers with header pairs as a record', () => { diff --git a/packages/acp-server/test/lifecycle.test.ts b/packages/acp-server/test/lifecycle.test.ts index fe124ca8d8..882939933d 100644 --- a/packages/acp-server/test/lifecycle.test.ts +++ b/packages/acp-server/test/lifecycle.test.ts @@ -291,10 +291,10 @@ describe('acp-server session lifecycle', () => { ); it( - 'session/new rejects stdio MCP servers without runtime identity', + 'session/new connects type-absent stdio MCP servers in the local runtime', async () => { const c = await boot(); - await expect(c.send('session/new', { + const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [ { @@ -304,30 +304,47 @@ describe('acp-server session lifecycle', () => { env: [{ name: 'KIMI_TEST_MCP_START_DELAY_MS', value: '0' }], }, ], - })).rejects.toThrow('ACP stdio MCP server mock does not declare a runtime identity'); + })) as { sessionId: string }; + expect(created.sessionId).toMatch(/^session_/); // Engine-side assertion: the session scope's MCP handle is the overlay // view and the converted server ended up connected under its ACP name. + const entries = await sessionMcpEntries(c, created.sessionId); + expect(entries.find((e) => e.name === 'mock')).toMatchObject({ + name: 'mock', + status: 'connected', + }); }, 30_000, ); it( - 'session/load rejects stdio MCP servers without runtime identity', + 'session/load connects type-absent stdio MCP servers after restart', async () => { const c = await boot(); const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as { sessionId: string; }; - await c.send('session/close', { sessionId: created.sessionId }); + await c.close(); + client = undefined; - await expect(c.send('session/load', { + client = await createTestClient({ homeDir: homeDir! }); + const restoredClient = client; + await restoredClient.send('initialize', { protocolVersion: 1, clientCapabilities: {} }); + + await restoredClient.send('session/load', { sessionId: created.sessionId, cwd: homeDir, mcpServers: [ { name: 'mock', command: process.execPath, args: [STDIO_MCP_FIXTURE], env: [] }, ], - })).rejects.toThrow('ACP stdio MCP server mock does not declare a runtime identity'); + }); + + const entries = await sessionMcpEntries(restoredClient, created.sessionId); + expect(entries.find((e) => e.name === 'mock')).toMatchObject({ + name: 'mock', + status: 'connected', + }); }, 30_000, ); From 49beed52f51f59bc965204adbb679833f036ef47 Mon Sep 17 00:00:00 2001 From: Fnine59 Date: Wed, 19 Aug 2026 11:01:51 +0800 Subject: [PATCH 2/2] fix(acp): preserve prototype-named MCP servers --- packages/acp-server/src/convert.ts | 2 +- packages/acp-server/test/convert.test.ts | 22 ++++++++++ packages/acp-server/test/lifecycle.test.ts | 6 +-- .../klient/src/contract/session/lifecycle.ts | 43 +++++++++++++++++-- packages/klient/test/contract.test.ts | 41 ++++++++++++++++++ 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/packages/acp-server/src/convert.ts b/packages/acp-server/src/convert.ts index 48c34bff63..53b53eef08 100644 --- a/packages/acp-server/src/convert.ts +++ b/packages/acp-server/src/convert.ts @@ -173,7 +173,7 @@ export function acpMcpServersToConfigRecord( servers: readonly McpServer[] | undefined, ): Record | undefined { if (servers === undefined || servers.length === 0) return undefined; - const out: Record = {}; + const out: Record = Object.create(null); for (const server of servers) { if (!('type' in server)) { out[server.name] = { diff --git a/packages/acp-server/test/convert.test.ts b/packages/acp-server/test/convert.test.ts index 4e267de645..46fca9c167 100644 --- a/packages/acp-server/test/convert.test.ts +++ b/packages/acp-server/test/convert.test.ts @@ -42,6 +42,28 @@ describe('acpMcpServersToConfigRecord', () => { }); }); + it('preserves stdio server names that match object prototype properties', () => { + const servers: McpServer[] = [ + { + name: '__proto__', + command: '/usr/local/bin/mcp-proto', + args: [], + env: [], + }, + ]; + + const converted = acpMcpServersToConfigRecord(servers); + expect(converted).toBeDefined(); + expect(Object.keys(converted ?? {})).toEqual(['__proto__']); + expect(converted?.['__proto__']).toEqual({ + transport: 'stdio', + command: '/usr/local/bin/mcp-proto', + args: [], + env: undefined, + runtime_id: 'local', + }); + }); + it('maps http and sse servers with header pairs as a record', () => { const servers: McpServer[] = [ { diff --git a/packages/acp-server/test/lifecycle.test.ts b/packages/acp-server/test/lifecycle.test.ts index 882939933d..887fbfd358 100644 --- a/packages/acp-server/test/lifecycle.test.ts +++ b/packages/acp-server/test/lifecycle.test.ts @@ -298,7 +298,7 @@ describe('acp-server session lifecycle', () => { cwd: homeDir, mcpServers: [ { - name: 'mock', + name: '__proto__', command: process.execPath, args: [STDIO_MCP_FIXTURE], env: [{ name: 'KIMI_TEST_MCP_START_DELAY_MS', value: '0' }], @@ -310,8 +310,8 @@ describe('acp-server session lifecycle', () => { // Engine-side assertion: the session scope's MCP handle is the overlay // view and the converted server ended up connected under its ACP name. const entries = await sessionMcpEntries(c, created.sessionId); - expect(entries.find((e) => e.name === 'mock')).toMatchObject({ - name: 'mock', + expect(entries.find((e) => e.name === '__proto__')).toMatchObject({ + name: '__proto__', status: 'connected', }); }, diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index acaf9170b5..d6caf477ae 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -8,9 +8,46 @@ import { z } from 'zod'; import { maybe, noResult } from '../helpers.js'; -import { mcpServerConfigSchema } from '../mcp.js'; +import { mcpServerConfigSchema, type McpServerConfig } from '../mcp.js'; import type { ServiceContract } from '../types.js'; +function isPlainRecord(value: unknown): value is Readonly> { + if (value === null || typeof value !== 'object') return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +const mcpServerConfigRecordSchema = z + .custom>>(isPlainRecord) + .transform((servers, ctx): Record => { + const out: Record = Object.create(null); + let valid = true; + for (const name of Reflect.ownKeys(servers)) { + const parsedName = z.string().safeParse(name); + if (!parsedName.success) { + valid = false; + ctx.addIssue({ + code: 'invalid_key', + origin: 'record', + issues: parsedName.error.issues, + path: [name], + }); + continue; + } + const config = servers[parsedName.data]; + const parsed = mcpServerConfigSchema.safeParse(config); + if (!parsed.success) { + valid = false; + for (const issue of parsed.error.issues) { + ctx.addIssue({ ...issue, path: [parsedName.data, ...issue.path] }); + } + continue; + } + out[parsedName.data] = parsed.data; + } + return valid ? out : z.NEVER; + }); + export const createSessionOptionsSchema = z.object({ sessionId: z.string().optional(), workDir: z.string(), @@ -19,7 +56,7 @@ export const createSessionOptionsSchema = z.object({ * Ephemeral per-session MCP servers (engine `CreateSessionOptions.mcpServers`): * connected only for the created session, never persisted. */ - mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), + mcpServers: mcpServerConfigRecordSchema.optional(), }); /** Same fields as `ResumeSessionOptions` in the engine — keep in sync. */ @@ -29,7 +66,7 @@ export const resumeSessionOptionsSchema = z.object({ * Ephemeral per-session MCP servers, applied when resume re-materializes a * cold session (ignored when the session is already live). */ - mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), + mcpServers: mcpServerConfigRecordSchema.optional(), }); /** Same fields as `ForkSessionOptions` in the engine — keep in sync. */ diff --git a/packages/klient/test/contract.test.ts b/packages/klient/test/contract.test.ts index 0d3b1ae6ba..4f3152d019 100644 --- a/packages/klient/test/contract.test.ts +++ b/packages/klient/test/contract.test.ts @@ -56,6 +56,47 @@ describe('MCP timeout contract validation', () => { }); }); + it('session creation options preserve prototype-named mcpServers', () => { + const parsed = createSessionOptionsSchema.safeParse({ + workDir: '/tmp/example', + mcpServers: { + ['__proto__']: { transport: 'stdio', command: 'node', runtime_id: 'local' }, + }, + }); + expect(parsed.success).toBe(true); + expect(Object.keys(parsed.data?.mcpServers ?? {})).toEqual(['__proto__']); + expect(parsed.data?.mcpServers?.['__proto__']).toEqual({ + transport: 'stdio', + command: 'node', + runtime_id: 'local', + }); + }); + + it('session creation options validate every own mcpServers key', () => { + const hiddenServers = {} as Record; + Object.defineProperty(hiddenServers, 'hidden', { + value: { transport: 'stdio', command: 'node' }, + }); + const hidden = createSessionOptionsSchema.safeParse({ + workDir: '/tmp/example', + mcpServers: hiddenServers, + }); + expect(hidden.success).toBe(true); + expect(Object.keys(hidden.data?.mcpServers ?? {})).toEqual(['hidden']); + + const symbol = Symbol('server'); + const symbolServers = { [symbol]: { transport: 'stdio', command: 'node' } }; + const invalid = createSessionOptionsSchema.safeParse({ + workDir: '/tmp/example', + mcpServers: symbolServers, + }); + expect(invalid.success).toBe(false); + expect(invalid.error?.issues[0]).toMatchObject({ + code: 'invalid_key', + path: ['mcpServers', symbol], + }); + }); + it('session creation options reject malformed mcpServers entries', () => { const parsed = createSessionOptionsSchema.safeParse({ workDir: '/tmp/example',