diff --git a/.changeset/fix-acp-stdio-mcp.md b/.changeset/fix-acp-stdio-mcp.md new file mode 100644 index 0000000000..82f3b356b9 --- /dev/null +++ b/.changeset/fix-acp-stdio-mcp.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow ACP MCP servers to use names that match built-in object properties. 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 5dd8482d24..123b9f8e18 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 5b505203f3..957e88f427 100644 --- a/packages/acp-server/test/lifecycle.test.ts +++ b/packages/acp-server/test/lifecycle.test.ts @@ -315,6 +315,31 @@ describe('acp-server session lifecycle', () => { 30_000, ); + it( + 'session/new preserves MCP server names that match object prototype properties', + async () => { + const c = await boot(); + const created = (await c.send('session/new', { + cwd: homeDir, + mcpServers: [ + { + name: '__proto__', + command: process.execPath, + args: [STDIO_MCP_FIXTURE], + env: [{ name: 'KIMI_TEST_MCP_START_DELAY_MS', value: '0' }], + }, + ], + })) as { sessionId: string }; + + const entries = await sessionMcpEntries(c, created.sessionId); + expect(entries.find((e) => e.name === '__proto__')).toMatchObject({ + name: '__proto__', + status: 'connected', + }); + }, + 30_000, + ); + it( 'session/load forwards mcpServers to the re-materialized session', async () => { 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 1571b6e68f..55ec19955a 100644 --- a/packages/klient/test/contract.test.ts +++ b/packages/klient/test/contract.test.ts @@ -57,6 +57,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',