Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-acp-stdio-mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Allow ACP MCP servers to use names that match built-in object properties.
2 changes: 1 addition & 1 deletion packages/acp-server/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export function acpMcpServersToConfigRecord(
servers: readonly McpServer[] | undefined,
): Record<string, McpServerConfig> | undefined {
if (servers === undefined || servers.length === 0) return undefined;
const out: Record<string, McpServerConfig> = {};
const out: Record<string, McpServerConfig> = Object.create(null);
for (const server of servers) {
if (!('type' in server)) {
out[server.name] = {
Comment thread
Fnine59 marked this conversation as resolved.
Expand Down
22 changes: 22 additions & 0 deletions packages/acp-server/test/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
{
Expand Down
25 changes: 25 additions & 0 deletions packages/acp-server/test/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
43 changes: 40 additions & 3 deletions packages/klient/src/contract/session/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
if (value === null || typeof value !== 'object') return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}

const mcpServerConfigRecordSchema = z
.custom<Readonly<Record<string, McpServerConfig>>>(isPlainRecord)
.transform((servers, ctx): Record<string, McpServerConfig> => {
const out: Record<string, McpServerConfig> = 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(),
Expand All @@ -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. */
Expand All @@ -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. */
Expand Down
41 changes: 41 additions & 0 deletions packages/klient/test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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',
Expand Down