Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8b856ee
fix(acp): support relayed stdio MCP servers
Fnine59 Aug 19, 2026
49beed5
fix(acp): preserve prototype-named MCP servers
Fnine59 Aug 19, 2026
00491cc
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 21, 2026
d87b04c
Merge branch 'main' into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 21, 2026
d7e19a2
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 22, 2026
74b2fdb
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 23, 2026
4101513
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 23, 2026
4fe6a55
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 26, 2026
a67d6c8
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 26, 2026
3920aa8
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 26, 2026
d92da4a
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 26, 2026
e361965
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 26, 2026
9e81f96
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 26, 2026
d022730
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 27, 2026
8776470
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 27, 2026
2617f19
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 27, 2026
8423586
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 27, 2026
570bb0d
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 27, 2026
1f8a92c
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 28, 2026
b761a3e
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 28, 2026
201bf60
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 29, 2026
5bdd062
Merge upstream/main into fix/kimi-code-acp-stdio-mcp-3069
Fnine59 Aug 30, 2026
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