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/mcp-server-deferred-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Add a per-server `deferred` field to MCP server configuration: when the model supports dynamic tool loading (experimental `tool-select` flag), set `deferred: false` to keep a server's tools in the top-level tool list instead of loading them on demand via `select_tools`.
1 change: 1 addition & 0 deletions docs/en/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Optional fields:
| `headers` | `Record<string, string>` | HTTP, SSE | Static request headers appended to every request |
| `bearerTokenEnvVar` | `string` | HTTP, SSE | Name of an environment variable that contains a bearer token |
| `enabled` | `boolean` | All | Set to `false` to disable this server |
| `deferred` | `boolean` | All | Experimental: with the `tool-select` flag and a model that declares dynamically loaded tools, this server's tools stay out of the top-level tool list and are loaded on demand via `select_tools`; set to `false` to always expose them inline. Defaults to `true`; ignored while the flag is off |
| `startupTimeoutMs` | `number` | All | Connection timeout from `1` to `2147483647` milliseconds; default `30000` |
| `toolTimeoutMs` | `number` | All | Timeout from `1` to `2147483647` milliseconds for a single tool call |
| `enabledTools` | `string[]` | All | Tool allowlist |
Expand Down
1 change: 1 addition & 0 deletions docs/zh/customization/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ MCP server 配置写在 `mcp.json` 中,分两层:
| `headers` | `Record<string, string>` | HTTP、SSE | 附加到每次请求的静态请求头 |
| `bearerTokenEnvVar` | `string` | HTTP、SSE | 存放 bearer token 的环境变量名 |
| `enabled` | `boolean` | 全部 | 设为 `false` 可禁用该 server |
| `deferred` | `boolean` | 全部 | 实验功能:启用 `tool-select` 标志且模型声明动态工具加载能力时,该 server 的工具默认不进入顶层工具列表,由模型通过 `select_tools` 按需加载;设为 `false` 则始终直接暴露。默认 `true`;标志未启用时该字段无效 |
| `startupTimeoutMs` | `number` | 全部 | 连接超时,取值范围为 `1` 到 `2147483647` 毫秒,默认 `30000` |
| `toolTimeoutMs` | `number` | 全部 | 单次工具调用超时,取值范围为 `1` 到 `2147483647` 毫秒 |
| `enabledTools` | `string[]` | 全部 | 工具白名单 |
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/agent/mcp/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export interface McpResolvedServer {
readonly tools: readonly KosongTool[];
readonly rawTools: readonly MCPToolDefinition[];
readonly enabledNames: ReadonlySet<string>;
readonly deferred: boolean;
}

export interface IAgentMcpService {
Expand Down
8 changes: 6 additions & 2 deletions packages/agent-core-v2/src/agent/mcp/mcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export class AgentMcpService extends Service implements IAgentMcpService {
resolved.client,
resolved.tools,
resolved.enabledNames,
resolved.deferred,
);
this.emitMcpToolCollisions(entry.name, result.collisions);
this.recordDiscovery(entry.name, resolved.rawTools, resolved.enabledNames, result.collisions);
Expand All @@ -234,7 +235,9 @@ export class AgentMcpService extends Service implements IAgentMcpService {
oauthService,
reconnect: (signal) => this.reconnect(entry.name, signal),
});
const disposable = this._register(this.registry.register(tool, { source: 'mcp' }));
const disposable = this._register(
this.registry.register(tool, { source: 'mcp', disclosure: 'deferred' }),
);
this.mcpTools.set(tool.name, { disposable, serverName: entry.name });
this.mcpToolsByServer.set(entry.name, [tool.name]);
void this.dispatcher.dispatch(
Expand All @@ -251,6 +254,7 @@ export class AgentMcpService extends Service implements IAgentMcpService {
client: MCPClient,
tools: readonly KosongTool[],
enabledTools: ReadonlySet<string>,
deferred: boolean,
): {
readonly registered: readonly string[];
readonly collisions: readonly McpToolCollision[];
Expand Down Expand Up @@ -290,7 +294,7 @@ export class AgentMcpService extends Service implements IAgentMcpService {
isRemoved: () =>
this.mcpHandle.connectionManager.get(serverName)?.status === 'removed',
}),
{ source: 'mcp' },
{ source: 'mcp', disclosure: deferred ? 'deferred' : 'inline' },
),
);
this.mcpTools.set(qualified, { disposable, serverName });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ export class AgentToolSelectService extends Service implements IAgentToolSelectS
}

private isDynamicallyLoadable(info: ToolInfo): boolean {
return info.source === 'mcp' || info.disclosure === 'deferred';
return info.disclosure === 'deferred';
}

private shapeActiveHistory(messages: readonly ContextMessage[]): readonly ContextMessage[] {
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/mcpCore/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const McpTimeoutMsSchema = z.number().int().min(1).max(MAX_MCP_TIMEOUT_MS

const McpServerCommonFields = {
enabled: z.boolean().optional(),
deferred: z.boolean().optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mirror deferred in the public client schemas

When callers configure an MCP server through @moonshot-ai/klient or the v2 node SDK, this option cannot reach the new engine field: packages/klient/src/contract/mcp.ts omits deferred, and Klient's Zod input/output parsing strips unknown properties before sending or returning configs; the node SDK also routes these operations through that contract. Consequently session-ephemeral, add/update, and test calls silently revert to the default deferred behavior, while reading and subsequently updating an existing deferred: false config can erase the setting. Add the field to the mirrored client schema/types and preserve it through node SDK validation.

AGENTS.md reference: AGENTS.md:L30-L30

Useful? React with 👍 / 👎.

startupTimeoutMs: McpTimeoutMsSchema.optional(),
toolTimeoutMs: McpTimeoutMsSchema.optional(),
enabledTools: z.array(z.string()).optional(),
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-core-v2/src/mcpCore/connection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface McpConnectionView {
tools: readonly Tool[];
rawTools: readonly MCPToolDefinition[];
enabledNames: ReadonlySet<string>;
deferred: boolean;
}
| undefined;
getRemoteServerUrl(name: string): string | undefined;
Expand Down Expand Up @@ -144,6 +145,7 @@ export class McpConnectionManager implements McpConnectionView {
tools: readonly Tool[];
rawTools: readonly MCPToolDefinition[];
enabledNames: ReadonlySet<string>;
deferred: boolean;
}
| undefined {
const entry = this.entries.get(name);
Expand All @@ -160,6 +162,7 @@ export class McpConnectionManager implements McpConnectionView {
tools: entry.tools,
rawTools: entry.rawTools,
enabledNames: entry.enabledNames ?? new Set(entry.tools.map((t) => t.name)),
deferred: entry.config.deferred !== false,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2045,7 +2045,7 @@ describe('FullCompaction', () => {
});
const registration = ctx
.get(IAgentToolRegistryService)
.register(mcpTool(LARGE_MCP_TOOL, parameters), { source: 'mcp' });
.register(mcpTool(LARGE_MCP_TOOL, parameters), { source: 'mcp', disclosure: 'deferred' });
try {
ctx.context.append({
role: 'system',
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-core-v2/test/agent/mcp/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ interface ResolvedServer {
readonly tools: readonly KosongTool[];
readonly rawTools: readonly MCPToolDefinition[];
readonly enabledNames: ReadonlySet<string>;
readonly deferred: boolean;
}

class FakeMcpManager {
Expand Down Expand Up @@ -118,6 +119,7 @@ class FakeMcpManager {
tools: readonly KosongTool[],
enabledNames = new Set(tools.map((tool) => tool.name)),
rawTools?: readonly MCPToolDefinition[],
deferred = true,
): void {
const resolvedRawTools =
rawTools ??
Expand All @@ -131,6 +133,7 @@ class FakeMcpManager {
tools,
rawTools: resolvedRawTools,
enabledNames,
deferred,
});
}

Expand Down Expand Up @@ -313,6 +316,7 @@ describe('AgentMcpService', () => {
'mcp__local_server__echo',
'mcp__local_server__noop',
]);
expect(infos.every((info) => info.disclosure === 'deferred')).toBe(true);
expect(events).toContainEqual(
expect.objectContaining({
type: 'tool.list.updated',
Expand All @@ -322,6 +326,19 @@ describe('AgentMcpService', () => {
);
});

it('registers tools of a deferred=false server with inline disclosure', async () => {
const manager = new FakeMcpManager();
const client = fakeMcpClient();
manager.setResolved('s', client, await discoverTools(client), undefined, undefined, false);
createService(manager);

manager.connect('s');

const infos = ix.get(IAgentToolRegistryService).list().filter((tool) => tool.source === 'mcp');
expect(infos.length).toBeGreaterThan(0);
expect(infos.every((info) => info.disclosure === 'inline')).toBe(true);
});

it('ignores status changes from servers outside the session baseline', async () => {
const manager = new FakeMcpManager();
const lateClient = fakeMcpClient();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ describe('progressive tool disclosure end-to-end', () => {
await ctx.restorePersisted();
await ctx.rpc.setPermission({ mode: 'yolo' });
alpha = new StubMcpTool(MCP_ALPHA);
registration = ctx.get(IAgentToolRegistryService).register(alpha, { source: 'mcp' });
registration = ctx
.get(IAgentToolRegistryService)
.register(alpha, { source: 'mcp', disclosure: 'deferred' });
});

afterEach(async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,8 +400,12 @@ function createExecutorHarness(): ExecutorHarness {
};
}

function registerMcp(h: Harness, tool: StubMcpTool): IDisposable {
const registration = h.registry.register(tool, { source: 'mcp' });
function registerMcp(
h: Harness,
tool: StubMcpTool,
disclosure: ToolDisclosure = 'deferred',
): IDisposable {
const registration = h.registry.register(tool, { source: 'mcp', disclosure });
disposables.add(registration);
return registration;
}
Expand Down Expand Up @@ -624,6 +628,21 @@ describe('AgentToolSelectService view shaping (gate open)', () => {
expect(byName.get(SELECT_TOOLS_TOOL_NAME)?.deferred).toBeUndefined();
});

it('keeps inline-disclosed MCP tools visible and out of the loadable manifest', () => {
const h = createHarness();
registerMcp(h, new StubMcpTool(MCP_ALPHA), 'inline');
registerMcp(h, new StubMcpTool(MCP_BETA));

const shaped = h.sut.shapeTools(h.registry.list());
const byName = new Map(shaped.map((entry) => [entry.name, entry]));
expect(byName.get(MCP_ALPHA)?.deferred).toBeUndefined();
expect(byName.has(MCP_BETA)).toBe(false);

const announcement = h.sut.loadableToolsAnnouncement();
expect(announcement).toContain(MCP_BETA);
expect(announcement).not.toContain(MCP_ALPHA);
});

it('defers only opted-in user tools and restores them after selection', () => {
const h = createHarness();
registerUser(h, new EchoTool(USER_DEFERRED), 'deferred');
Expand Down Expand Up @@ -1083,7 +1102,10 @@ describe('AgentToolSelectService loadable-tools announcements', () => {
it('diffs registry additions and removals against the folded announcements', async () => {
const h = createHarness();
registerMcp(h, new StubMcpTool(MCP_ALPHA));
const betaRegistration = h.registry.register(new StubMcpTool(MCP_BETA), { source: 'mcp' });
const betaRegistration = h.registry.register(new StubMcpTool(MCP_BETA), {
source: 'mcp',
disclosure: 'deferred',
});
disposables.add(betaRegistration);

await announce(h);
Expand Down
14 changes: 14 additions & 0 deletions packages/agent-core-v2/test/mcpCore/connection-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,20 @@ describe('McpConnectionManager', () => {
}
}, 15000);

it('reflects the deferred config field in the resolved view', async () => {
const cm = createManager();
try {
await cm.connectAll({
plain: stdioConfig(),
pinned: { ...stdioConfig(), deferred: false },
});
expect(cm.resolved('plain')?.deferred).toBe(true);
expect(cm.resolved('pinned')?.deferred).toBe(false);
} finally {
await cm.shutdown();
}
}, 15000);

it('starts stdio servers in stdioCwd when config.cwd is omitted', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'kimi-mcp-manager-cwd-'));
const cm = createManager({ stdioCwd: cwd });
Expand Down
1 change: 1 addition & 0 deletions packages/klient/src/contract/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const mcpTimeoutMsSchema = z.number().int().min(1).max(2_147_483_647);

const mcpServerCommonFields = {
enabled: z.boolean().optional(),
deferred: z.boolean().optional(),
startupTimeoutMs: mcpTimeoutMsSchema.optional(),
toolTimeoutMs: mcpTimeoutMsSchema.optional(),
enabledTools: z.array(z.string()).optional(),
Expand Down
26 changes: 26 additions & 0 deletions packages/klient/test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,32 @@ describe('MCP timeout contract validation', () => {
});
});

it('session creation options preserve the per-server deferred field', () => {
const parsed = createSessionOptionsSchema.safeParse({
workDir: '/tmp/example',
mcpServers: {
stdioExample: { transport: 'stdio', command: 'node', deferred: false },
httpExample: { transport: 'http', url: 'https://example.com/mcp', deferred: true },
sseExample: { transport: 'sse', url: 'https://example.com/sse' },
},
});
expect(parsed.success).toBe(true);
expect(parsed.data?.mcpServers?.['stdioExample']).toEqual({
transport: 'stdio',
command: 'node',
deferred: false,
});
expect(parsed.data?.mcpServers?.['httpExample']).toEqual({
transport: 'http',
url: 'https://example.com/mcp',
deferred: true,
});
expect(parsed.data?.mcpServers?.['sseExample']).toEqual({
transport: 'sse',
url: 'https://example.com/sse',
});
});

it('session creation options reject malformed mcpServers entries', () => {
const parsed = createSessionOptionsSchema.safeParse({
workDir: '/tmp/example',
Expand Down
Loading