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 clients to use relayed stdio MCP servers.
11 changes: 9 additions & 2 deletions packages/acp-server/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,17 @@ 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)) {
throw new Error(`ACP stdio MCP server ${server.name} does not declare a runtime identity`);
out[server.name] = {
Comment thread
Fnine59 marked this conversation as resolved.
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] = {
Expand Down
36 changes: 32 additions & 4 deletions packages/acp-server/test/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -31,9 +31,37 @@ 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('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', () => {
Expand Down
33 changes: 25 additions & 8 deletions packages/acp-server/test/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,43 +291,60 @@ 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: [
{
name: 'mock',
name: '__proto__',
command: process.execPath,
args: [STDIO_MCP_FIXTURE],
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 === '__proto__')).toMatchObject({
name: '__proto__',
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,
);
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 @@ -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<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