From 6a4433523870ecc20d50c5937fef07ac1e6552f3 Mon Sep 17 00:00:00 2001 From: lofyer Date: Tue, 11 Aug 2026 23:49:18 +0800 Subject: [PATCH] feat: support dynamic MCP tool loading --- src/main/agent/model-runtime.test.ts | 102 +++++++++ src/main/agent/model-runtime.ts | 64 +++--- .../model-tool-provider.integration.test.ts | 76 +++++++ src/main/agent/model-tool-provider.test.ts | 126 ++++++++++- src/main/agent/model-tool-provider.ts | 202 ++++++++++++------ .../capabilities/capability-service.test.ts | 64 +++++- src/main/capabilities/capability-service.ts | 25 ++- src/main/capabilities/mcp-tester.test.ts | 23 ++ src/main/capabilities/mcp-tester.ts | 3 + src/renderer/src/App.test.tsx | 1 + src/renderer/src/McpSettingsSection.tsx | 108 ++++++---- src/renderer/src/SettingsPanel.test.tsx | 23 ++ .../src/i18n/locales/en-US/integrations.ts | 7 + .../src/i18n/locales/zh-CN/integrations.ts | 6 + src/shared/capability-contracts.ts | 5 + 15 files changed, 695 insertions(+), 140 deletions(-) create mode 100644 src/main/agent/model-tool-provider.integration.test.ts diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index 0672211..b5825f0 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -757,6 +757,108 @@ describe('ModelAgentRuntime', () => { expect(toolProvider.dispose).toHaveBeenCalledOnce() }) + it('uses refreshed tool definitions in subsequent model rounds', async () => { + const loadTool: ModelToolDefinition = { + name: 'mcp_load_tools', + displayName: 'CRM / load tools', + description: 'Load CRM tools', + inputSchema: { type: 'object' }, + source: 'mcp', + serverName: 'CRM' + } + const dynamicTool: ModelToolDefinition = { + name: 'mcp_list_opportunities', + displayName: 'CRM / list opportunities', + description: 'List opportunities', + inputSchema: { type: 'object' }, + source: 'mcp', + serverName: 'CRM' + } + const listTools = vi + .fn() + .mockResolvedValueOnce([loadTool]) + .mockResolvedValueOnce([loadTool, dynamicTool]) + .mockResolvedValueOnce([loadTool, dynamicTool]) + const toolProvider = createToolProvider({ listTools }) + const responses = [ + { + choices: [{ + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call-load', + type: 'function', + function: { + name: loadTool.name, + arguments: '{}' + } + }] + } + }] + }, + { + choices: [{ + message: { + role: 'assistant', + content: null, + tool_calls: [{ + id: 'call-list', + type: 'function', + function: { + name: dynamicTool.name, + arguments: '{}' + } + }] + } + }] + }, + { + choices: [{ + message: { + role: 'assistant', + content: '已读取商机。' + } + }] + } + ] + const fetcher = vi.fn(async () => + Response.json(responses.shift()) + ) + const runtime = new ModelAgentRuntime({ + baseUrl: 'http://127.0.0.1:11434/v1', + model: 'qwen3', + protocol: 'openai-chat-completions', + authentication: 'none', + fetcher, + toolProvider + }) + + for await (const _event of runtime.run( + { + requestId: 'a431666e-5ec8-45e6-beb4-654132eed139', + conversationId: 'conversation-dynamic-tools', + prompt: '列出商机', + workMode: 'execute' + }, + new AbortController().signal, + vi.fn(async () => 'once' as const) + )) { + void _event + } + + expect(listTools).toHaveBeenCalledTimes(3) + const secondBody = JSON.parse( + fetcher.mock.calls[1]?.[1]?.body as string + ) as { + tools: Array<{ function: { name: string } }> + } + expect(secondBody.tools.map((tool) => tool.function.name)).toContain( + dynamicTool.name + ) + expect(toolProvider.callTool).toHaveBeenCalledTimes(2) + }) + it('runs only scoped knowledge in Ask without requesting approval', async () => { const responses = [ { diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index d78a5e0..dbb6750 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -1442,32 +1442,41 @@ export class ModelAgentRuntime implements AgentRuntime { workMode: request.workMode ?? 'ask', knowledgeCapabilityToken: request.knowledgeCapabilityToken } - const tools = await this.toolProvider.listTools(toolContext, signal) - if (tools.length === 0 || tools.length > 100) { - throw new Error('直连模型工具数量无效') - } - const toolPayload = JSON.stringify( - tools.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema - })) - ) - if (Buffer.byteLength(toolPayload) > 512 * 1024) { - throw new Error('直连模型工具定义超过 512KB 安全限制') - } - const toolsByName = new Map(tools.map((tool) => [tool.name, tool])) - if ( - toolsByName.size !== tools.length || - tools.some( - (tool) => - !/^[a-zA-Z0-9_-]{1,64}$/u.test(tool.name) || - !tool.displayName || - tool.displayName.length > 200 + const loadToolSnapshot = async (): Promise<{ + tools: ModelToolDefinition[] + toolsByName: Map + }> => { + const tools = await this.toolProvider.listTools(toolContext, signal) + if (tools.length === 0 || tools.length > 100) { + throw new Error('直连模型工具数量无效') + } + const toolPayload = JSON.stringify( + tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema + })) ) - ) { - throw new Error('直连模型工具定义包含无效或重复名称') + if (Buffer.byteLength(toolPayload) > 512 * 1024) { + throw new Error('直连模型工具定义超过 512KB 安全限制') + } + const toolsByName = new Map( + tools.map((tool) => [tool.name, tool]) + ) + if ( + toolsByName.size !== tools.length || + tools.some( + (tool) => + !/^[a-zA-Z0-9_-]{1,64}$/u.test(tool.name) || + !tool.displayName || + tool.displayName.length > 200 + ) + ) { + throw new Error('直连模型工具定义包含无效或重复名称') + } + return { tools, toolsByName } } + let toolSnapshot = await loadToolSnapshot() const baseMessages = anthropic ? (this.getAnthropicMessages(request) as Array>) : responses @@ -1484,9 +1493,12 @@ export class ModelAgentRuntime implements AgentRuntime { for (let round = 0; round < maxToolRounds; round += 1) { signal.throwIfAborted() + if (round > 0) { + toolSnapshot = await loadToolSnapshot() + } const response = await this.requestToolModel( messages, - tools, + toolSnapshot.tools, system, anthropic, signal @@ -1584,7 +1596,7 @@ export class ModelAgentRuntime implements AgentRuntime { throw new Error('模型重复使用了工具调用 ID') } seenCallIds.add(call.id) - const tool = toolsByName.get(call.name) + const tool = toolSnapshot.toolsByName.get(call.name) const displayName = tool?.displayName ?? call.name.slice(0, 128) const input = boundedToolDetail(call.arguments, 4_000) yield { diff --git a/src/main/agent/model-tool-provider.integration.test.ts b/src/main/agent/model-tool-provider.integration.test.ts new file mode 100644 index 0000000..78d9b0d --- /dev/null +++ b/src/main/agent/model-tool-provider.integration.test.ts @@ -0,0 +1,76 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import type { ResolvedMcpServer } from '../capabilities/capability-service' +import { ModelToolProvider } from './model-tool-provider' + +const temporaryDirectories: string[] = [] +const crmToken = process.env.GOODBUDDY_TEST_CRM_MCP_TOKEN?.trim() +const externalTest = crmToken ? it : it.skip + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => + rm(directory, { recursive: true, force: true }) + ) + ) +}) + +externalTest( + 'refreshes tools from a real dynamic MCP server', + async () => { + const workspace = await mkdtemp( + join(tmpdir(), 'goodbuddy-dynamic-mcp-') + ) + temporaryDirectories.push(workspace) + const server: ResolvedMcpServer = { + id: '00000000-0000-4000-8000-000000000401', + name: 'CRM', + description: '', + enabled: true, + allowDynamicTools: true, + assignments: ['model'], + secretConfigured: true, + secret: crmToken, + transport: 'http', + url: 'https://crm.digiman.live/mcp' + } + const provider = new ModelToolProvider(workspace, [server]) + const signal = new AbortController().signal + const context = { + conversationId: 'dynamic-mcp-integration', + workMode: 'execute' + } as const + + try { + const initialTools = await provider.listTools(context, signal) + const loadTool = initialTools.find( + (tool) => + tool.displayName === 'CRM / crmtools_load_tools' + ) + expect(loadTool).toBeDefined() + + await provider.callTool( + loadTool?.name ?? '', + { groups: ['opportunity'] }, + signal, + context + ) + + const refreshedTools = await provider.listTools(context, signal) + expect(refreshedTools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + displayName: 'CRM / crmtools_list_opportunities' + }) + ]) + ) + } finally { + await provider.dispose() + } + }, + 20_000 +) diff --git a/src/main/agent/model-tool-provider.test.ts b/src/main/agent/model-tool-provider.test.ts index b4ef243..a13c965 100644 --- a/src/main/agent/model-tool-provider.test.ts +++ b/src/main/agent/model-tool-provider.test.ts @@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => { const client = { connect: vi.fn(), listTools: vi.fn(), + getServerCapabilities: vi.fn(), callTool: vi.fn(), experimental: { tasks }, close: vi.fn() @@ -28,7 +29,12 @@ const mocks = vi.hoisted(() => { return { client, tasks, - Client: vi.fn(function Client() { + Client: vi.fn(function Client( + _info: unknown, + _options?: unknown + ) { + void _info + void _options return client }), createMcpTransport: vi.fn(() => ({ kind: 'test-transport' })) @@ -87,12 +93,15 @@ function createBrowserService(): BrowserToolService { } } -function createMcpServer(): ResolvedMcpServer { +function createMcpServer( + allowDynamicTools = false +): ResolvedMcpServer { return { id: 'd2ef774b-146c-4467-a909-6feb112a9c2c', name: 'Search MCP', description: '', enabled: true, + allowDynamicTools, assignments: ['model'], secretConfigured: false, transport: 'stdio', @@ -112,6 +121,9 @@ describe('ModelToolProvider', () => { vi.clearAllMocks() mocks.client.connect.mockResolvedValue(undefined) mocks.client.listTools.mockResolvedValue({ tools: [] }) + mocks.client.getServerCapabilities.mockReturnValue({ + tools: { listChanged: false } + }) mocks.client.callTool.mockResolvedValue({ content: [{ type: 'text', text: 'MCP result' }] }) @@ -748,6 +760,116 @@ describe('ModelToolProvider', () => { expect(mocks.client.close).toHaveBeenCalledOnce() }) + it('refreshes opted-in dynamic MCP tools between model rounds', async () => { + const workspace = await createWorkspace() + mocks.client.getServerCapabilities.mockReturnValue({ + tools: { listChanged: true } + }) + mocks.client.listTools + .mockResolvedValueOnce({ + tools: [ + { + name: 'crmtools_load_tools', + inputSchema: { + type: 'object', + properties: { + groups: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['groups'] + } + } + ] + }) + .mockResolvedValueOnce({ + tools: [ + { + name: 'crmtools_load_tools', + inputSchema: { + type: 'object', + properties: { + groups: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['groups'] + } + }, + { + name: 'crmtools_list_opportunities', + inputSchema: { type: 'object' } + } + ] + }) + const provider = new ModelToolProvider( + workspace, + [createMcpServer(true)] + ) + const signal = new AbortController().signal + + const initialTools = await provider.listTools(toolContext, signal) + const loadTool = initialTools.find( + (tool) => tool.displayName === + 'Search MCP / crmtools_load_tools' + ) + expect(loadTool).toBeDefined() + const clientOptions = mocks.Client.mock.calls[0]?.[1] as + | { + listChanged: { + tools: { + onChanged: ( + error: Error | null, + tools: unknown[] | null + ) => void + } + } + } + | undefined + expect(clientOptions).toBeDefined() + if (!clientOptions) { + throw new Error('Expected dynamic MCP client options') + } + clientOptions.listChanged.tools.onChanged(null, null) + await provider.callTool( + loadTool?.name ?? '', + { groups: ['opportunity'] }, + signal, + toolContext + ) + const refreshedTools = await provider.listTools( + toolContext, + signal + ) + + expect(mocks.Client).toHaveBeenCalledWith( + { + name: 'goodbuddy-direct-model', + version: '0.1.0' + }, + expect.objectContaining({ + listChanged: { + tools: expect.objectContaining({ + autoRefresh: false, + debounceMs: 0, + onChanged: expect.any(Function) + }) + } + }) + ) + expect(mocks.client.listTools).toHaveBeenCalledTimes(2) + expect(refreshedTools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + displayName: + 'Search MCP / crmtools_list_opportunities' + }) + ]) + ) + }) + it('preserves ordered bounded MCP text, image, and unsupported audio parts', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index 47cc19b..4c2dddb 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -53,6 +53,7 @@ const EXA_MCP_SERVER: ResolvedMcpServer = { name: 'Exa Web Search', description: 'GoodBuddy 直连模型内置联网搜索', enabled: true, + allowDynamicTools: false, assignments: ['model'], secretConfigured: false, transport: 'http', @@ -255,7 +256,10 @@ type McpToolBinding = { type ConnectedMcp = { client: Client + server: ResolvedMcpServer tools: McpToolBinding[] + dynamicToolsSupported: boolean + dynamicToolsChanged: boolean } function boundedJson(value: unknown, errorMessage: string): string { @@ -490,7 +494,7 @@ function normalizeMcpResult(result: unknown): ModelToolResult { export class ModelToolProvider implements ModelToolProviderLike { private canonicalWorkspace?: Promise - private mcpBindings?: Promise> + private mcpConnections?: Promise private webSearchBindings?: Promise> private readonly clients = new Set() private readonly customMcpClients = new Set() @@ -982,10 +986,28 @@ export class ModelToolProvider implements ModelToolProviderLike { signal: AbortSignal, clientScope: Set = this.customMcpClients ): Promise { - const client = new Client({ - name: 'goodbuddy-direct-model', - version: '0.1.0' - }) + let connection: ConnectedMcp | undefined + const client = new Client( + { + name: 'goodbuddy-direct-model', + version: '0.1.0' + }, + server.allowDynamicTools + ? { + listChanged: { + tools: { + autoRefresh: false, + debounceMs: 0, + onChanged: (error) => { + if (!error && connection) { + connection.dynamicToolsChanged = true + } + } + } + } + } + : undefined + ) this.clients.add(client) clientScope.add(client) try { @@ -997,48 +1019,16 @@ export class ModelToolProvider implements ModelToolProviderLike { timeout: MCP_TIMEOUT_MS, signal }) - const reservedToolCount = this.getReservedToolCount() - if (result.tools.length > MAX_MODEL_TOOLS - reservedToolCount) { - throw new Error( - `MCP Server「${server.name}」提供的工具数量超过安全限制` - ) - } - const tools = result.tools.map((tool): McpToolBinding => ({ + connection = { client, - originalName: tool.name, - readOnly: - tool.annotations?.readOnlyHint === true && - tool.annotations?.destructiveHint !== true, - definition: { - name: createMcpToolName(server.id, tool.name), - displayName: `${server.name} / ${tool.name}`.slice(0, 200), - description: [ - `MCP Server「${server.name}」提供的工具。`, - tool.description - ] - .filter(Boolean) - .join(' ') - .slice(0, 1_000), - inputSchema: normalizeToolSchema(tool.inputSchema), - source: 'mcp', - serverName: server.name, - taskSupport: tool.execution?.taskSupport - } - })) - if ( - tools.some( - (tool) => - !tool.originalName || - tool.originalName.length > 128 || - [...tool.originalName].some((character) => { - const code = character.charCodeAt(0) - return code <= 31 || code === 127 - }) - ) - ) { - throw new Error(`MCP Server「${server.name}」返回了无效工具名称`) + server, + tools: this.createMcpBindings(client, server, result.tools), + dynamicToolsSupported: + server.allowDynamicTools && + client.getServerCapabilities()?.tools?.listChanged === true, + dynamicToolsChanged: false } - return { client, tools } + return connection } catch (error) { this.clients.delete(client) clientScope.delete(client) @@ -1049,33 +1039,67 @@ export class ModelToolProvider implements ModelToolProviderLike { } } + private createMcpBindings( + client: Client, + server: ResolvedMcpServer, + tools: Awaited>['tools'] + ): McpToolBinding[] { + const reservedToolCount = this.getReservedToolCount() + if (tools.length > MAX_MODEL_TOOLS - reservedToolCount) { + throw new Error( + `MCP Server「${server.name}」提供的工具数量超过安全限制` + ) + } + const bindings = tools.map((tool): McpToolBinding => ({ + client, + originalName: tool.name, + readOnly: + tool.annotations?.readOnlyHint === true && + tool.annotations?.destructiveHint !== true, + definition: { + name: createMcpToolName(server.id, tool.name), + displayName: `${server.name} / ${tool.name}`.slice(0, 200), + description: [ + `MCP Server「${server.name}」提供的工具。`, + tool.description + ] + .filter(Boolean) + .join(' ') + .slice(0, 1_000), + inputSchema: normalizeToolSchema(tool.inputSchema), + source: 'mcp', + serverName: server.name, + taskSupport: tool.execution?.taskSupport + } + })) + if ( + bindings.some( + (tool) => + !tool.originalName || + tool.originalName.length > 128 || + [...tool.originalName].some((character) => { + const code = character.charCodeAt(0) + return code <= 31 || code === 127 + }) + ) + ) { + throw new Error(`MCP Server「${server.name}」返回了无效工具名称`) + } + return bindings + } + private async getMcpBindings( - signal: AbortSignal + signal: AbortSignal, + refreshDynamic = false ): Promise> { if (this.mcpServers.length > MAX_MCP_SERVERS) { throw new Error('直连模型最多可加载 16 个 MCP Server') } - this.mcpBindings ??= Promise.all( + this.mcpConnections ??= Promise.all( this.mcpServers.map((server) => this.connectMcpServer(server, signal)) ) - .then((connections) => { - const bindings = new Map() - const reservedToolCount = this.getReservedToolCount() - for (const connection of connections) { - for (const binding of connection.tools) { - if (bindings.size + reservedToolCount >= MAX_MODEL_TOOLS) { - throw new Error('直连模型工具总数超过 100 个安全限制') - } - if (bindings.has(binding.definition.name)) { - throw new Error('MCP 工具名称发生冲突') - } - bindings.set(binding.definition.name, binding) - } - } - return bindings - }) .catch(async (error) => { - this.mcpBindings = undefined + this.mcpConnections = undefined const clients = [...this.customMcpClients] this.customMcpClients.clear() clients.forEach((client) => this.clients.delete(client)) @@ -1084,7 +1108,51 @@ export class ModelToolProvider implements ModelToolProviderLike { ) throw error }) - return this.mcpBindings + const connections = await this.mcpConnections + if (refreshDynamic) { + await Promise.all( + connections.map(async (connection) => { + if ( + !connection.dynamicToolsSupported || + !connection.dynamicToolsChanged + ) { + return + } + connection.dynamicToolsChanged = false + try { + const result = await connection.client.listTools(undefined, { + timeout: MCP_TIMEOUT_MS, + signal + }) + connection.tools = this.createMcpBindings( + connection.client, + connection.server, + result.tools + ) + } catch (error) { + connection.dynamicToolsChanged = true + throw new Error( + `无法刷新 MCP Server「${connection.server.name}」的工具`, + { cause: error } + ) + } + }) + ) + } + const bindings = new Map() + const reservedToolCount = this.getReservedToolCount() + for (const connection of connections) { + for (const binding of connection.tools) { + if (bindings.size + reservedToolCount >= MAX_MODEL_TOOLS) { + throw new Error('直连模型工具总数超过 100 个安全限制') + } + if (bindings.has(binding.definition.name)) { + throw new Error('MCP 工具名称发生冲突') + } + bindings.set(binding.definition.name, binding) + } + } + return bindings } private async getWebSearchBindings( @@ -1157,7 +1225,7 @@ export class ModelToolProvider implements ModelToolProviderLike { if (context.workMode !== 'execute') { return [...webTools, ...scopedTools] } - const bindings = await this.getMcpBindings(signal) + const bindings = await this.getMcpBindings(signal, true) const browserTools = this.getBrowserTools(context) return [ ...this.getBuiltinTools(), @@ -1631,7 +1699,7 @@ export class ModelToolProvider implements ModelToolProviderLike { this.clients.clear() this.customMcpClients.clear() this.webSearchClients.clear() - this.mcpBindings = undefined + this.mcpConnections = undefined this.webSearchBindings = undefined await Promise.allSettled(clients.map((client) => client.close())) } diff --git a/src/main/capabilities/capability-service.test.ts b/src/main/capabilities/capability-service.test.ts index 5501962..7024cfb 100644 --- a/src/main/capabilities/capability-service.test.ts +++ b/src/main/capabilities/capability-service.test.ts @@ -471,6 +471,7 @@ describe('CapabilityService', () => { name: 'Remote MCP', description: 'Remote test server', enabled: true, + allowDynamicTools: true, assignments: ['model'], secret: { action: 'replace', value: 'secret-token-value' }, transport: 'http', @@ -480,6 +481,7 @@ describe('CapabilityService', () => { expect(server).toMatchObject({ name: 'Remote MCP', transport: 'http', + allowDynamicTools: true, secretConfigured: true }) expect(JSON.stringify(snapshot)).not.toContain('secret-token-value') @@ -502,6 +504,7 @@ describe('CapabilityService', () => { name: 'Local MCP', description: '', enabled: true, + allowDynamicTools: false, assignments: ['model'], secret: { action: 'keep' }, transport: 'stdio', @@ -524,6 +527,7 @@ describe('CapabilityService', () => { name: 'Loopback MCP', description: '', enabled: true, + allowDynamicTools: false, assignments: ['model'], secret: { action: 'replace', value: 'secret-token-value' }, transport: 'http', @@ -546,6 +550,7 @@ describe('CapabilityService', () => { name: 'Intranet MCP', description: '', enabled: true, + allowDynamicTools: false, assignments: ['model'], secret: { action: 'replace', value: 'secret-token-value' }, transport: 'http', @@ -577,6 +582,7 @@ describe('CapabilityService', () => { name: 'Public plaintext MCP', description: '', enabled: true, + allowDynamicTools: false, assignments: ['model'], secret: { action: 'replace', value: 'secret-token-value' }, transport: 'http', @@ -596,6 +602,7 @@ describe('CapabilityService', () => { name: 'Public MCP without token', description: '', enabled: true, + allowDynamicTools: false, assignments: ['model'], secret: { action: 'clear' }, transport: 'http', @@ -619,6 +626,7 @@ describe('CapabilityService', () => { name: 'Agent MCP', description: '', enabled: true, + allowDynamicTools: false, assignments: ['opencode'], secret: { action: 'keep' }, transport: 'stdio', @@ -729,6 +737,7 @@ describe('CapabilityService', () => { mcpServers: [ expect.objectContaining({ name: 'Preserved MCP', + allowDynamicTools: false, secretConfigured: true }) ], @@ -748,7 +757,7 @@ describe('CapabilityService', () => { } }) const persisted = await readFile(filePath, 'utf8') - expect(persisted).toContain('"version": 3') + expect(persisted).toContain('"version": 4') expect(persisted).toContain(credential) expect(persisted).not.toContain('preserved-secret') }) @@ -784,7 +793,58 @@ describe('CapabilityService', () => { await expect(service.getSnapshot()).resolves.toMatchObject({ webSearch: { enabled: true } }) - expect(await readFile(filePath, 'utf8')).toContain('"version": 3') + expect(await readFile(filePath, 'utf8')).toContain('"version": 4') + }) + + it('migrates v3 MCP servers with dynamic tools disabled', async () => { + const { filePath, builtinRoot, importedRoot } = await createService() + await writeFile( + filePath, + JSON.stringify({ + version: 3, + skills: {}, + mcpServers: [ + { + id: 'd2ef774b-146c-4467-a909-6feb112a9c2c', + name: 'Legacy dynamic MCP', + description: '', + enabled: true, + assignments: ['model'], + transport: 'http', + url: 'https://mcp.example.com/mcp' + } + ], + webSearch: { enabled: true }, + computerCapabilities: { + 'host-browser-control': { + enabled: false, + browserProfileId: null + }, + 'linux-desktop-control': { + enabled: false, + browserProfileId: null + } + } + }), + 'utf8' + ) + const service = new CapabilityService( + filePath, + builtinRoot, + importedRoot, + cipher + ) + + await expect(service.getSnapshot()).resolves.toMatchObject({ + mcpServers: [ + expect.objectContaining({ + allowDynamicTools: false + }) + ] + }) + const persisted = await readFile(filePath, 'utf8') + expect(persisted).toContain('"version": 4') + expect(persisted).toContain('"allowDynamicTools": false') }) it('gates enablement on the supported platform and architecture', async () => { diff --git a/src/main/capabilities/capability-service.ts b/src/main/capabilities/capability-service.ts index 54fe925..0b44989 100644 --- a/src/main/capabilities/capability-service.ts +++ b/src/main/capabilities/capability-service.ts @@ -103,6 +103,7 @@ const storedMcpCommonShape = { name: z.string(), description: z.string(), enabled: z.boolean(), + allowDynamicTools: z.boolean().default(false), assignments: capabilityAssignmentsSchema, credential: encryptedSecretSchema } @@ -167,7 +168,7 @@ const webSearchStateSchema = z }) .strict() -const storedCapabilitiesSchema = z +const storedCapabilitiesV3Schema = z .object({ version: z.literal(3), skills: z.record(skillIdSchema, skillStateSchema), @@ -182,6 +183,10 @@ const storedCapabilitiesSchema = z }) .strict() +const storedCapabilitiesSchema = storedCapabilitiesV3Schema.extend({ + version: z.literal(4) +}) + type StoredCapabilitiesV1 = z.infer type StoredCapabilities = z.infer type StoredMcpServer = z.infer @@ -238,7 +243,7 @@ function defaultComputerCapabilityStates(): StoredCapabilities['computerCapabili function emptyStoredCapabilities(): StoredCapabilities { return { - version: 3, + version: 4, skills: {}, mcpServers: [], webSearch: { enabled: true }, @@ -635,7 +640,8 @@ export class CapabilityService { version: z.union([ z.literal(1), z.literal(2), - z.literal(3) + z.literal(3), + z.literal(4) ]) }) .passthrough() @@ -644,7 +650,7 @@ export class CapabilityService { const legacy: StoredCapabilitiesV1 = storedCapabilitiesV1Schema.parse(raw) loaded = { - version: 3, + version: 4, skills: legacy.skills, mcpServers: legacy.mcpServers, webSearch: { enabled: true }, @@ -655,10 +661,17 @@ export class CapabilityService { const legacy = storedCapabilitiesV2Schema.parse(raw) loaded = { ...legacy, - version: 3, + version: 4, webSearch: { enabled: true } } shouldPersist = true + } else if (version === 3) { + const legacy = storedCapabilitiesV3Schema.parse(raw) + loaded = { + ...legacy, + version: 4 + } + shouldPersist = true } else { loaded = storedCapabilitiesSchema.parse(raw) } @@ -1319,6 +1332,7 @@ export class CapabilityService { name: value.name, description: value.description, enabled: value.enabled, + allowDynamicTools: value.allowDynamicTools, assignments: value.assignments, transport: 'stdio', command: value.command, @@ -1329,6 +1343,7 @@ export class CapabilityService { name: value.name, description: value.description, enabled: value.enabled, + allowDynamicTools: value.allowDynamicTools, assignments: value.assignments, credential, transport: value.transport, diff --git a/src/main/capabilities/mcp-tester.test.ts b/src/main/capabilities/mcp-tester.test.ts index 1413794..3dbfd3a 100644 --- a/src/main/capabilities/mcp-tester.test.ts +++ b/src/main/capabilities/mcp-tester.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => { connect: vi.fn(), listTools: vi.fn(), getServerVersion: vi.fn(), + getServerCapabilities: vi.fn(), close: vi.fn() } return { @@ -55,6 +56,7 @@ const common = { name: 'Test MCP', description: '', enabled: true, + allowDynamicTools: false, assignments: ['model'] as Array<'model' | 'opencode' | 'continue'>, secretConfigured: false } @@ -75,6 +77,9 @@ describe('testMcpServer', () => { name: 'test-server', version: '1.0.0' }) + mocks.client.getServerCapabilities.mockReturnValue({ + tools: { listChanged: false } + }) mocks.client.close.mockResolvedValue(undefined) }) @@ -98,11 +103,29 @@ describe('testMcpServer', () => { expect(result).toEqual({ serverName: 'test-server', serverVersion: '1.0.0', + dynamicToolsSupported: false, toolCount: 1, tools: [{ name: 'search', description: 'Search documents' }] }) }) + it('reports support for dynamic tool-list notifications', async () => { + mocks.client.getServerCapabilities.mockReturnValue({ + tools: { listChanged: true } + }) + + await expect( + testMcpServer({ + ...common, + transport: 'stdio', + command: 'node', + args: ['server.js'] + } satisfies ResolvedMcpServer) + ).resolves.toMatchObject({ + dynamicToolsSupported: true + }) + }) + it('injects a bearer token only into the remote transport', async () => { await testMcpServer({ ...common, diff --git a/src/main/capabilities/mcp-tester.ts b/src/main/capabilities/mcp-tester.ts index 589fe91..41aa62a 100644 --- a/src/main/capabilities/mcp-tester.ts +++ b/src/main/capabilities/mcp-tester.ts @@ -56,9 +56,12 @@ export async function testMcpServer( }) ) const version = client.getServerVersion() + const capabilities = client.getServerCapabilities() return { serverName: version?.name.slice(0, 120), serverVersion: version?.version.slice(0, 64), + dynamicToolsSupported: + capabilities?.tools?.listChanged === true, toolCount: result.tools.length, tools: result.tools.slice(0, 100).map((tool) => ({ name: tool.name.slice(0, 128), diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 44a7380..eae3e0f 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -438,6 +438,7 @@ const api: DesktopApi = { mcpServers: [] })), testMcpServer: vi.fn(async () => ({ + dynamicToolsSupported: false, toolCount: 0, tools: [] })) diff --git a/src/renderer/src/McpSettingsSection.tsx b/src/renderer/src/McpSettingsSection.tsx index 5ae3fd2..b1b41c5 100644 --- a/src/renderer/src/McpSettingsSection.tsx +++ b/src/renderer/src/McpSettingsSection.tsx @@ -42,6 +42,7 @@ type McpEditor = { name: string description: string enabled: boolean + allowDynamicTools: boolean assignments: CapabilityAssignments transport: McpTransport command: string @@ -55,6 +56,7 @@ const emptyEditor: McpEditor = { name: '', description: '', enabled: true, + allowDynamicTools: false, assignments: ['model'], transport: 'stdio', command: '', @@ -70,6 +72,7 @@ function editorFromServer(server: McpServerSummary): McpEditor { name: server.name, description: server.description, enabled: server.enabled, + allowDynamicTools: server.allowDynamicTools, assignments: server.assignments.includes('model') ? ['model'] : [], @@ -255,6 +258,7 @@ export function McpSettingsSection(): React.JSX.Element { name: editor.name, description: editor.description, enabled: editor.enabled, + allowDynamicTools: editor.allowDynamicTools, assignments: editor.assignments, secret } @@ -1223,6 +1227,24 @@ export function McpSettingsSection(): React.JSX.Element { /> {t('mcp.editor.enable')} + +

+ {t('mcp.editor.allowDynamicToolsDescription')} +

{t('mcp.editor.assignTo')} {configurableMcpTargets.map( @@ -1316,6 +1338,9 @@ export function McpSettingsSection(): React.JSX.Element { {server.secretConfigured ? t('mcp.custom.encryptedToken') : ''} + {server.allowDynamicTools + ? t('mcp.custom.dynamicToolsEnabled') + : ''}
@@ -1400,44 +1425,51 @@ export function McpSettingsSection(): React.JSX.Element { {result ? ( -
-
- - {result.serverName || server.name} - {result.serverVersion - ? ` ${result.serverVersion}` - : ''} - - - {t('mcp.builtin.toolCount', { - count: result.toolCount - })} - -
- {result.tools.length > 0 ? ( -
    - {result.tools.map((tool) => ( -
  • -
    - {tool.name} -
    - {tool.description && ( -

    {tool.description}

    - )} -
  • - ))} -
- ) : ( -

- {t('mcp.custom.noTools')} -

- )} -
+ <> +

+ {result.dynamicToolsSupported + ? t('mcp.custom.dynamicToolsSupported') + : t('mcp.custom.dynamicToolsUnsupported')} +

+
+
+ + {result.serverName || server.name} + {result.serverVersion + ? ` ${result.serverVersion}` + : ''} + + + {t('mcp.builtin.toolCount', { + count: result.toolCount + })} + +
+ {result.tools.length > 0 ? ( +
    + {result.tools.map((tool) => ( +
  • +
    + {tool.name} +
    + {tool.description && ( +

    {tool.description}

    + )} +
  • + ))} +
+ ) : ( +

+ {t('mcp.custom.noTools')} +

+ )} +
+ ) : (

{t('mcp.custom.testHelp')} diff --git a/src/renderer/src/SettingsPanel.test.tsx b/src/renderer/src/SettingsPanel.test.tsx index 76ff4ce..59c241d 100644 --- a/src/renderer/src/SettingsPanel.test.tsx +++ b/src/renderer/src/SettingsPanel.test.tsx @@ -465,6 +465,7 @@ describe('SettingsPanel runtime files', () => { saveMcpServer, removeMcpServer: vi.fn(async () => capabilitySnapshot), testMcpServer: vi.fn(async () => ({ + dynamicToolsSupported: false, toolCount: 0, tools: [] })), @@ -2461,6 +2462,11 @@ describe('SettingsPanel runtime files', () => { name: '启用此 MCP Server' }) ).toBeChecked() + expect( + within(dialog).getByRole('switch', { + name: '允许动态更新工具列表' + }) + ).not.toBeChecked() expect(within(dialog).getByLabelText('模型')).toBeChecked() expect( within(dialog).queryByLabelText('OpenCode') @@ -2519,6 +2525,11 @@ describe('SettingsPanel runtime files', () => { fireEvent.change(within(dialog).getByLabelText('Bearer Token'), { target: { value: ' token-with-significant-spaces ' } }) + fireEvent.click( + within(dialog).getByRole('switch', { + name: '允许动态更新工具列表' + }) + ) fireEvent.click(saveButton) await waitFor(() => expect(saveMcpServer).toHaveBeenCalledWith( @@ -2527,6 +2538,7 @@ describe('SettingsPanel runtime files', () => { name: '本地文件工具', transport: 'http', url: 'https://mcp.example.com/mcp', + allowDynamicTools: true, secret: { action: 'replace', value: ' token-with-significant-spaces ' @@ -2563,6 +2575,7 @@ describe('SettingsPanel runtime files', () => { name: '团队知识服务', description: '公司内部 MCP', enabled: true, + allowDynamicTools: true, assignments: ['model'], secretConfigured: true, transport: 'http', @@ -2593,6 +2606,11 @@ describe('SettingsPanel runtime files', () => { '团队知识服务' ) expect(within(dialog).getByLabelText('Bearer Token')).toHaveValue('') + expect( + within(dialog).getByRole('switch', { + name: '允许动态更新工具列表' + }) + ).toBeChecked() fireEvent.keyDown(dialog, { key: 'Escape' }) await waitFor(() => expect(editButton).toHaveFocus()) expect( @@ -2609,6 +2627,7 @@ describe('SettingsPanel runtime files', () => { name: '团队工具服务', description: '公司内部工具', enabled: true, + allowDynamicTools: true, assignments: ['model'], secretConfigured: false, transport: 'http', @@ -2621,6 +2640,7 @@ describe('SettingsPanel runtime files', () => { ).mockResolvedValueOnce({ serverName: 'Team MCP', serverVersion: '1.2.0', + dynamicToolsSupported: true, toolCount: 1, tools: [ { @@ -2651,6 +2671,9 @@ describe('SettingsPanel runtime files', () => { screen.getByRole('button', { name: '测试 团队工具服务' }) ) expect(await screen.findByText('team_search')).toBeInTheDocument() + expect( + screen.getByText('服务端支持动态更新工具列表') + ).toBeInTheDocument() expect(serverToggle).toHaveAttribute('aria-expanded', 'true') expect( screen.getByRole('region', { name: '团队工具服务 工具' }) diff --git a/src/renderer/src/i18n/locales/en-US/integrations.ts b/src/renderer/src/i18n/locales/en-US/integrations.ts index 166003d..5b920a6 100644 --- a/src/renderer/src/i18n/locales/en-US/integrations.ts +++ b/src/renderer/src/i18n/locales/en-US/integrations.ts @@ -275,6 +275,9 @@ export const integrations = { optional: 'Optional', clearToken: 'Clear the saved Bearer Token when saving', enable: 'Enable this MCP server', + allowDynamicTools: 'Allow dynamic tool-list updates', + allowDynamicToolsDescription: + 'Applies only when a trusted server advertises support. Updated tools are revalidated and used in the next model round, and existing approval controls still apply.', assignTo: 'Assign to', cancel: 'Cancel', saving: 'Saving…', @@ -291,6 +294,10 @@ export const integrations = { enabled: 'Enabled', disabled: 'Disabled', encryptedToken: ' · Encrypted token', + dynamicToolsEnabled: ' · Dynamic tools allowed', + dynamicToolsSupported: 'Server supports dynamic tool-list updates', + dynamicToolsUnsupported: + 'Server does not advertise dynamic tool-list updates', toolsUndetected: 'Tools not checked', testAriaLabel: 'Test {{name}}', test: 'Test', diff --git a/src/renderer/src/i18n/locales/zh-CN/integrations.ts b/src/renderer/src/i18n/locales/zh-CN/integrations.ts index 3182d40..79d0056 100644 --- a/src/renderer/src/i18n/locales/zh-CN/integrations.ts +++ b/src/renderer/src/i18n/locales/zh-CN/integrations.ts @@ -260,6 +260,9 @@ export const integrations = { optional: '可选', clearToken: '保存时清除已保存的 Bearer Token', enable: '启用此 MCP Server', + allowDynamicTools: '允许动态更新工具列表', + allowDynamicToolsDescription: + '仅在可信 Server 声明支持时生效。工具列表变化后会重新验证并在下一轮模型请求中使用,新工具仍需经过现有审批。', assignTo: '分配给', cancel: '取消', saving: '保存中…', @@ -276,6 +279,9 @@ export const integrations = { enabled: '已启用', disabled: '已停用', encryptedToken: ' · 已加密令牌', + dynamicToolsEnabled: ' · 允许动态工具', + dynamicToolsSupported: '服务端支持动态更新工具列表', + dynamicToolsUnsupported: '服务端未声明支持动态更新工具列表', toolsUndetected: '工具未检测', testAriaLabel: '测试 {{name}}', test: '测试', diff --git a/src/shared/capability-contracts.ts b/src/shared/capability-contracts.ts index 34020a0..5832f52 100644 --- a/src/shared/capability-contracts.ts +++ b/src/shared/capability-contracts.ts @@ -232,6 +232,7 @@ const mcpCommonInputShape = { name: mcpServerNameSchema, description: mcpServerDescriptionSchema, enabled: z.boolean(), + allowDynamicTools: z.boolean(), assignments: capabilityAssignmentsSchema, secret: secretActionSchema } @@ -269,6 +270,7 @@ export const mcpServerSummarySchema = z.discriminatedUnion('transport', [ name: mcpServerNameSchema, description: mcpServerDescriptionSchema, enabled: z.boolean(), + allowDynamicTools: z.boolean(), assignments: capabilityAssignmentsSchema, secretConfigured: z.boolean(), transport: z.literal('stdio'), @@ -282,6 +284,7 @@ export const mcpServerSummarySchema = z.discriminatedUnion('transport', [ name: mcpServerNameSchema, description: mcpServerDescriptionSchema, enabled: z.boolean(), + allowDynamicTools: z.boolean(), assignments: capabilityAssignmentsSchema, secretConfigured: z.boolean(), transport: z.literal('http'), @@ -294,6 +297,7 @@ export const mcpServerSummarySchema = z.discriminatedUnion('transport', [ name: mcpServerNameSchema, description: mcpServerDescriptionSchema, enabled: z.boolean(), + allowDynamicTools: z.boolean(), assignments: capabilityAssignmentsSchema, secretConfigured: z.boolean(), transport: z.literal('sse'), @@ -336,6 +340,7 @@ export const mcpServerTestResultSchema = z .object({ serverName: z.string().min(1).max(120).optional(), serverVersion: z.string().min(1).max(64).optional(), + dynamicToolsSupported: z.boolean(), toolCount: z.number().int().min(0).max(10_000), tools: z .array(