diff --git a/.changeset/dynamic-tools-official-models.md b/.changeset/dynamic-tools-official-models.md new file mode 100644 index 00000000000..69a35db5cd6 --- /dev/null +++ b/.changeset/dynamic-tools-official-models.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add the dynamically_loaded_tools capability to official Kimi Code models when the service declares support for message-level tool declarations. diff --git a/.changeset/mcp-server-deferred-config.md b/.changeset/mcp-server-deferred-config.md new file mode 100644 index 00000000000..87fc41ca8fb --- /dev/null +++ b/.changeset/mcp-server-deferred-config.md @@ -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`. diff --git a/.changeset/select-tools-profile-activation.md b/.changeset/select-tools-profile-activation.md new file mode 100644 index 00000000000..a4c8d4430ef --- /dev/null +++ b/.changeset/select-tools-profile-activation.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the select_tools tool never being registered because agent profiles do not list it in their tool allowlists. diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index 29f08109e6f..844378cb347 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -62,6 +62,7 @@ Optional fields: | `headers` | `Record` | 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 | diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index bd1faf85440..ca0782b1d9e 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -62,8 +62,9 @@ MCP server 配置写在 `mcp.json` 中,分两层: | `headers` | `Record` | HTTP、SSE | 附加到每次请求的静态请求头 | | `bearerTokenEnvVar` | `string` | HTTP、SSE | 存放 bearer token 的环境变量名 | | `enabled` | `boolean` | 全部 | 设为 `false` 可禁用该 server | -| `startupTimeoutMs` | `number` | 全部 | 连接超时,默认 `30000` 毫秒 | -| `toolTimeoutMs` | `number` | 全部 | 单次工具调用超时(毫秒) | +| `deferred` | `boolean` | 全部 | 实验功能:启用 `tool-select` 标志且模型声明动态工具加载能力时,该 server 的工具默认不进入顶层工具列表,由模型通过 `select_tools` 按需加载;设为 `false` 则始终直接暴露。默认 `true`;标志未启用时该字段无效 | +| `startupTimeoutMs` | `number` | 全部 | 连接超时,取值范围为 `1` 到 `2147483647` 毫秒,默认 `30000` | +| `toolTimeoutMs` | `number` | 全部 | 单次工具调用超时,取值范围为 `1` 到 `2147483647` 毫秒 | | `enabledTools` | `string[]` | 全部 | 工具白名单 | | `disabledTools` | `string[]` | 全部 | 工具黑名单 | diff --git a/packages/agent-core-v2/src/agent/mcp/mcp.ts b/packages/agent-core-v2/src/agent/mcp/mcp.ts index 013adc5deb0..8dd2db51998 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcp.ts @@ -11,6 +11,7 @@ export interface McpResolvedServer { readonly tools: readonly KosongTool[]; readonly rawTools: readonly MCPToolDefinition[]; readonly enabledNames: ReadonlySet; + readonly deferred: boolean; } export interface IAgentMcpService { diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index bddf19ee362..0d1126677bf 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -212,6 +212,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); @@ -235,7 +236,13 @@ 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 deferred = this.mcpHandle.connectionManager.configOf(entry.name)?.deferred !== false; + const disposable = this._register( + this.registry.register(tool, { + source: 'mcp', + disclosure: deferred ? 'deferred' : 'inline', + }), + ); this.mcpTools.set(tool.name, { disposable, serverName: entry.name }); this.mcpToolsByServer.set(entry.name, [tool.name]); void this.dispatcher.dispatch( @@ -252,6 +259,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { client: MCPClient, tools: readonly KosongTool[], enabledTools: ReadonlySet, + deferred: boolean, ): { readonly registered: readonly string[]; readonly collisions: readonly McpToolCollision[]; @@ -292,7 +300,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 }); diff --git a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts index 83e041ebb3d..93414c8f9a0 100644 --- a/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts +++ b/packages/agent-core-v2/src/agent/toolActivation/toolActivationService.ts @@ -8,6 +8,7 @@ import { IEventBus } from '#/app/event/eventBus'; import { IAgentProfileService } from '#/agent/profile/profile'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { isToolActive } from '#/agent/toolPolicy/evaluate'; +import { SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolContribution } from '#/agent/toolRegistry/toolContribution'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; @@ -55,6 +56,7 @@ export class AgentToolActivationService extends Service implements IAgentToolAct if (records.length === 0) return; const data = this.profile.data(); const policy = { tools: data.activeToolNames, disallowedTools: data.disallowedTools }; + const disclosurePolicy = { disallowedTools: data.disallowedTools }; const workspaceVeto = { disallowedTools: this.toolPolicyGate.disabledTools }; this.instantiationService.invokeFunction((accessor) => { for (const record of records) { @@ -63,7 +65,11 @@ export class AgentToolActivationService extends Service implements IAgentToolAct if (this.toolRegistry.resolve(options.name) !== undefined) continue; if (!this.runtimeAllows(record)) continue; if (!isToolActive(workspaceVeto, options.name, source)) continue; - if (!isToolActive(policy, options.name, source)) continue; + const activeByProfile = + options.name === SELECT_TOOLS_TOOL_NAME + ? isToolActive(disclosurePolicy, options.name, source) + : isToolActive(policy, options.name, source); + if (!activeByProfile) continue; if (options.when !== undefined && !options.when(accessor)) continue; const tool = accessor.get(id); const registration = this.toolRegistry.register(tool, { diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index 6a389808314..c8ab1d60636 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -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[] { diff --git a/packages/agent-core-v2/src/mcpCore/config-schema.ts b/packages/agent-core-v2/src/mcpCore/config-schema.ts index 83afa8c70cf..e88a81dc20d 100644 --- a/packages/agent-core-v2/src/mcpCore/config-schema.ts +++ b/packages/agent-core-v2/src/mcpCore/config-schema.ts @@ -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(), startupTimeoutMs: McpTimeoutMsSchema.optional(), toolTimeoutMs: McpTimeoutMsSchema.optional(), enabledTools: z.array(z.string()).optional(), diff --git a/packages/agent-core-v2/src/mcpCore/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts index 8774de86f6f..1b638c28e60 100644 --- a/packages/agent-core-v2/src/mcpCore/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -41,6 +41,7 @@ export interface McpConnectionView { readonly oauthService: McpOAuthService | undefined; list(): readonly McpServerEntry[]; get(name: string): McpServerEntry | undefined; + configOf(name: string): McpServerConfig | undefined; resolved( name: string, ): @@ -49,6 +50,7 @@ export interface McpConnectionView { tools: readonly Tool[]; rawTools: readonly MCPToolDefinition[]; enabledNames: ReadonlySet; + deferred: boolean; } | undefined; getRemoteServerUrl(name: string): string | undefined; @@ -144,6 +146,7 @@ export class McpConnectionManager implements McpConnectionView { tools: readonly Tool[]; rawTools: readonly MCPToolDefinition[]; enabledNames: ReadonlySet; + deferred: boolean; } | undefined { const entry = this.entries.get(name); @@ -160,6 +163,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, }; } diff --git a/packages/agent-core-v2/src/session/mcp/mergedConnectionView.ts b/packages/agent-core-v2/src/session/mcp/mergedConnectionView.ts index 2affb4fc7cf..d2030097956 100644 --- a/packages/agent-core-v2/src/session/mcp/mergedConnectionView.ts +++ b/packages/agent-core-v2/src/session/mcp/mergedConnectionView.ts @@ -4,6 +4,7 @@ import type { McpServerEntry, McpStatusListener, } from '#/mcpCore/connection-manager'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; import type { McpOAuthService } from '#/mcpCore/oauth/service'; import { abortable } from '#/_base/utils/abort'; @@ -27,6 +28,10 @@ export class MergedMcpConnectionView implements McpConnectionView { return this.owner(name).get(name); } + configOf(name: string): McpServerConfig | undefined { + return this.owner(name).configOf(name); + } + resolved(name: string): ReturnType { return this.owner(name).resolved(name); } diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index 795b03dacc7..fcf33c822a1 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -2114,7 +2114,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', diff --git a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts index 43a30f6bc69..26e91221899 100644 --- a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts @@ -15,6 +15,7 @@ import type { Event2 } from '#/app/event/event2'; import { IEventBus } from '#/app/event/eventBus'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { McpConnectionManager, McpServerEntry } from '#/mcpCore/connection-manager'; +import type { McpServerConfig } from '#/mcpCore/config-schema'; import { IAgentMcpService } from '#/agent/mcp/mcp'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; import { AgentMcpService } from '#/agent/mcp/mcpService'; @@ -57,10 +58,12 @@ interface ResolvedServer { readonly tools: readonly KosongTool[]; readonly rawTools: readonly MCPToolDefinition[]; readonly enabledNames: ReadonlySet; + readonly deferred: boolean; } class FakeMcpManager { private readonly entries = new Map(); + private readonly configs = new Map(); private readonly resolvedEntries = new Map(); private readonly listeners = new Set<(entry: McpServerEntry) => void>(); readonly oauthService: McpOAuthService | undefined; @@ -77,6 +80,10 @@ class FakeMcpManager { return this.entries.get(name); } + configOf(name: string): McpServerConfig | undefined { + return this.configs.get(name); + } + resolved(name: string): ResolvedServer | undefined { if (this.entries.get(name)?.status !== 'connected') return undefined; return this.resolvedEntries.get(name); @@ -125,6 +132,7 @@ class FakeMcpManager { tools: readonly KosongTool[], enabledNames = new Set(tools.map((tool) => tool.name)), rawTools?: readonly MCPToolDefinition[], + deferred = true, ): void { const resolvedRawTools = rawTools ?? @@ -138,6 +146,7 @@ class FakeMcpManager { tools, rawTools: resolvedRawTools, enabledNames, + deferred, }); } @@ -153,7 +162,10 @@ class FakeMcpManager { this.emit(entry); } - needsAuth(name = 'needs-auth'): void { + needsAuth(name = 'needs-auth', options: { readonly deferred?: boolean } = {}): void { + if (options.deferred !== undefined) { + this.configs.set(name, { deferred: options.deferred } as unknown as McpServerConfig); + } const entry: McpServerEntry = { name, transport: 'http', @@ -321,6 +333,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', @@ -364,6 +377,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(); @@ -1223,7 +1249,7 @@ describe('AgentMcpService', () => { expect(receivedSignal).toBe(controller.signal); }); - it('registers a synthetic authenticate tool when a server needs auth', () => { + it('registers a synthetic authenticate tool inline when the server declares deferred: false', () => { const oauthService = { beginAuthorization: async () => ({ authorizationUrl: new URL('https://example.com/authorize'), @@ -1234,13 +1260,14 @@ describe('AgentMcpService', () => { const manager = new FakeMcpManager({ oauthService }); createService(manager); - manager.needsAuth(); + manager.needsAuth('needs-auth', { deferred: false }); const tools = ix.get(IAgentToolRegistryService).list(); expect(tools).toEqual([ expect.objectContaining({ name: 'mcp__needs-auth__authenticate', source: 'mcp', + disclosure: 'inline', }), ]); }); @@ -1263,6 +1290,7 @@ describe('AgentMcpService', () => { expect.objectContaining({ name: 'mcp__needs-auth__authenticate', source: 'mcp', + disclosure: 'deferred', }), ]); expect(events).toContainEqual( diff --git a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts index fae87b35446..c0e0cc6c050 100644 --- a/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts +++ b/packages/agent-core-v2/test/agent/toolActivation/toolActivationService.test.ts @@ -32,6 +32,10 @@ import { } from '#/agent/toolRegistry/toolContribution'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; +import { + IAgentToolSelectService, + SELECT_TOOLS_TOOL_NAME, +} from '#/agent/toolSelect/toolSelect'; import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import type { RuntimeCapability } from '#/runtime/runtime'; import type { AgentTool, ToolExecution } from '#/tool/toolContract'; @@ -66,6 +70,7 @@ const IAlphaTool = createDecorator('activationTestAlphaTool'); const IBetaTool = createDecorator('activationTestBetaTool'); const IGammaTool = createDecorator('activationTestGammaTool'); const IAgentStubTool = createDecorator('activationTestAgentTool'); +const ISelectToolsStub = createDecorator('activationTestSelectToolsStub'); let alphaConstructions = 0; let betaConstructions = 0; @@ -98,6 +103,12 @@ class AgentStubTool extends StubTool { } } +class SelectToolsStub extends StubTool { + constructor() { + super(SELECT_TOOLS_TOOL_NAME); + } +} + class TestContributionAssembly extends Service { constructor() { super(); @@ -175,6 +186,7 @@ describe('AgentToolActivationService', () => { reg.define(IBetaTool, BetaTool); reg.define(IGammaTool, GammaTool); reg.define(IAgentStubTool, AgentStubTool); + reg.define(ISelectToolsStub, SelectToolsStub); }, }); disposables.add(ix.createInstance(TestContributionAssembly)); @@ -330,6 +342,7 @@ describe('AgentToolActivationService', () => { profileData.activeToolNames = ['Alpha']; registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); registerAgentToolService(IBetaTool, BetaTool, { name: 'Beta' }); + registerAgentToolService(ISelectToolsStub, SelectToolsStub, { name: SELECT_TOOLS_TOOL_NAME }); const ix = createActivationHost(); await ix.get(IAgentToolActivationService).activate(); @@ -337,13 +350,15 @@ describe('AgentToolActivationService', () => { const registry = ix.get(IAgentToolRegistryService); expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); expect(registry.resolve('Beta')).toBeUndefined(); + expect(registry.resolve(SELECT_TOOLS_TOOL_NAME)).toBeInstanceOf(SelectToolsStub); expect(betaConstructions).toBe(0); }); it('honors the profile disallowedTools', async () => { - profileData.disallowedTools = ['Beta']; + profileData.disallowedTools = ['Beta', SELECT_TOOLS_TOOL_NAME]; registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' }); registerAgentToolService(IBetaTool, BetaTool, { name: 'Beta' }); + registerAgentToolService(ISelectToolsStub, SelectToolsStub, { name: SELECT_TOOLS_TOOL_NAME }); const ix = createActivationHost(); await ix.get(IAgentToolActivationService).activate(); @@ -351,6 +366,7 @@ describe('AgentToolActivationService', () => { const registry = ix.get(IAgentToolRegistryService); expect(registry.resolve('Alpha')).toBeInstanceOf(AlphaTool); expect(registry.resolve('Beta')).toBeUndefined(); + expect(registry.resolve(SELECT_TOOLS_TOOL_NAME)).toBeUndefined(); expect(betaConstructions).toBe(0); }); @@ -539,7 +555,9 @@ describe('AgentToolActivationService', () => { registerAgentToolService(contribution.id, contribution.ctor, contribution.options); } profileData.activeToolNames = []; - const { app, agent } = createScopeTree(); + const { app, agent } = createScopeTree([ + [IAgentToolSelectService, {} as IAgentToolSelectService], + ]); const probe = app.accessor.get(ICollectionProbe); expect(probe.view.items).toHaveLength(savedContributions.length); @@ -552,7 +570,8 @@ describe('AgentToolActivationService', () => { } await agent.accessor.get(IAgentToolActivationService).activate(); - expect(agent.accessor.get(IAgentToolRegistryService).list()).toHaveLength(0); + const registered = agent.accessor.get(IAgentToolRegistryService).list(); + expect(registered.map((tool) => tool.name)).toEqual([SELECT_TOOLS_TOOL_NAME]); app.dispose(); }); }); diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts index 24e5749745d..6d57da21a2e 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts @@ -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 () => { diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 2fef0fb5132..02befb96b60 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -410,8 +410,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; } @@ -634,6 +638,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'); @@ -1093,7 +1112,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); diff --git a/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts b/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts index bb32b85f80c..5e2d2e8e901 100644 --- a/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts +++ b/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts @@ -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 }); diff --git a/packages/klient/src/contract/mcp.ts b/packages/klient/src/contract/mcp.ts index ed8827a7d23..674e0f05c08 100644 --- a/packages/klient/src/contract/mcp.ts +++ b/packages/klient/src/contract/mcp.ts @@ -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(), diff --git a/packages/klient/test/contract.test.ts b/packages/klient/test/contract.test.ts index 1571b6e68ff..3181e53afc0 100644 --- a/packages/klient/test/contract.test.ts +++ b/packages/klient/test/contract.test.ts @@ -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', diff --git a/packages/oauth/src/managed-kimi-code.ts b/packages/oauth/src/managed-kimi-code.ts index 021bed71dfe..936283b8f5d 100644 --- a/packages/oauth/src/managed-kimi-code.ts +++ b/packages/oauth/src/managed-kimi-code.ts @@ -37,6 +37,7 @@ export interface ManagedKimiCodeModelInfo { readonly supportsImageIn: boolean; readonly supportsVideoIn: boolean; readonly supportsToolUse?: boolean; + readonly supportsDynamicTools?: boolean; readonly supportsThinkingType?: SupportsThinkingType; readonly supportEfforts?: readonly string[]; readonly defaultEffort?: string; @@ -249,6 +250,7 @@ function capabilitiesForModel(model: ManagedKimiCodeModelInfo): string[] | undef if (model.supportsImageIn) caps.add('image_in'); if (model.supportsVideoIn) caps.add('video_in'); if (model.supportsToolUse ?? true) caps.add('tool_use'); + if (model.supportsDynamicTools === true) caps.add('dynamically_loaded_tools'); return caps.size > 0 ? [...caps] : undefined; } @@ -438,6 +440,7 @@ function toModelInfo(item: unknown): ManagedKimiCodeModelInfo | undefined { supportsImageIn: Boolean(item['supports_image_in']), supportsVideoIn: Boolean(item['supports_video_in']), supportsToolUse, + supportsDynamicTools: item['supports_dynamic_tools'] === true, supportsThinkingType: parseSupportsThinkingType(item['supports_thinking_type']), supportEfforts: thinkEfforts.supportEfforts, defaultEffort: thinkEfforts.defaultEffort, diff --git a/packages/oauth/test/managed-kimi-code.test.ts b/packages/oauth/test/managed-kimi-code.test.ts index 67c31d822c3..10d9c0825e9 100644 --- a/packages/oauth/test/managed-kimi-code.test.ts +++ b/packages/oauth/test/managed-kimi-code.test.ts @@ -853,6 +853,7 @@ describe('supports_thinking_type', () => { supports_image_in: true, supports_video_in: true, supports_thinking_type: 'only', + supports_dynamic_tools: true, display_name: 'Kimi For Coding', }, { @@ -875,7 +876,7 @@ describe('supports_thinking_type', () => { ); } - it('parses supports_thinking_type from the models endpoint', async () => { + it('parses supports_thinking_type and supports_dynamic_tools from the models endpoint', async () => { const models = await fetchManagedKimiCodeModels({ accessToken: 'oauth-access-token', fetchImpl: vi.fn(async () => makeThinkingTypeModelsResponse()) as unknown as typeof fetch, @@ -884,6 +885,8 @@ describe('supports_thinking_type', () => { expect(models[0]?.supportsThinkingType).toBe('only'); expect(models[1]?.supportsThinkingType).toBe('no'); expect(models[2]?.supportsThinkingType).toBe('both'); + expect(models[0]?.supportsDynamicTools).toBe(true); + expect(models[1]?.supportsDynamicTools).toBe(false); }); it('leaves supportsThinkingType undefined when the field is absent or invalid', async () => { @@ -928,13 +931,14 @@ describe('supports_thinking_type', () => { }, }); - // 'only' → thinking locked on. + // 'only' → thinking locked on; supports_dynamic_tools adds dynamically_loaded_tools. expect(config.models?.['kimi-code/kimi-for-coding']?.capabilities).toEqual([ 'thinking', 'always_thinking', 'image_in', 'video_in', 'tool_use', + 'dynamically_loaded_tools', ]); // 'no' → no thinking capability despite supports_reasoning=true. expect(config.models?.['kimi-code/kimi-plain']?.capabilities).toEqual(['tool_use']);