feat: support dynamic MCP tool loading

This commit is contained in:
lofyer
2026-08-11 23:49:18 +08:00
parent 98d7166ab3
commit 6a44335238
15 changed files with 695 additions and 140 deletions
+102
View File
@@ -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<ModelToolProviderLike['listTools']>()
.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<typeof fetch>(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 = [
{
+38 -26
View File
@@ -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<string, ModelToolDefinition>
}> => {
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<Record<string, unknown>>)
: 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 {
@@ -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
)
+124 -2
View File
@@ -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({
+135 -67
View File
@@ -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<string>
private mcpBindings?: Promise<Map<string, McpToolBinding>>
private mcpConnections?: Promise<ConnectedMcp[]>
private webSearchBindings?: Promise<Map<string, McpToolBinding>>
private readonly clients = new Set<Client>()
private readonly customMcpClients = new Set<Client>()
@@ -982,10 +986,28 @@ export class ModelToolProvider implements ModelToolProviderLike {
signal: AbortSignal,
clientScope: Set<Client> = this.customMcpClients
): Promise<ConnectedMcp> {
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<ReturnType<Client['listTools']>>['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<Map<string, McpToolBinding>> {
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<string, McpToolBinding>()
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<string, McpToolBinding>()
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()))
}
@@ -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 () => {
+20 -5
View File
@@ -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<typeof storedCapabilitiesV1Schema>
type StoredCapabilities = z.infer<typeof storedCapabilitiesSchema>
type StoredMcpServer = z.infer<typeof storedMcpServerSchema>
@@ -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,
+23
View File
@@ -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,
+3
View File
@@ -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),
+1
View File
@@ -438,6 +438,7 @@ const api: DesktopApi = {
mcpServers: []
})),
testMcpServer: vi.fn(async () => ({
dynamicToolsSupported: false,
toolCount: 0,
tools: []
}))
+70 -38
View File
@@ -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 {
/>
<span>{t('mcp.editor.enable')}</span>
</label>
<label className="toggle-row">
<input
aria-label={t('mcp.editor.allowDynamicTools')}
checked={editor.allowDynamicTools}
onChange={(event) =>
setEditor({
...editor,
allowDynamicTools: event.target.checked
})
}
role="switch"
type="checkbox"
/>
<span>{t('mcp.editor.allowDynamicTools')}</span>
</label>
<p className="settings-notice">
{t('mcp.editor.allowDynamicToolsDescription')}
</p>
<div className="runtime-assignments">
<small>{t('mcp.editor.assignTo')}</small>
{configurableMcpTargets.map(
@@ -1316,6 +1338,9 @@ export function McpSettingsSection(): React.JSX.Element {
{server.secretConfigured
? t('mcp.custom.encryptedToken')
: ''}
{server.allowDynamicTools
? t('mcp.custom.dynamicToolsEnabled')
: ''}
</small>
</div>
<span className="mcp-server-card__summary">
@@ -1400,44 +1425,51 @@ export function McpSettingsSection(): React.JSX.Element {
</span>
</div>
{result ? (
<section
aria-label={t('mcp.builtin.toolsAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>
{result.serverName || server.name}
{result.serverVersion
? ` ${result.serverVersion}`
: ''}
</strong>
<small>
{t('mcp.builtin.toolCount', {
count: result.toolCount
})}
</small>
</div>
{result.tools.length > 0 ? (
<ul>
{result.tools.map((tool) => (
<li key={tool.name}>
<div>
<code>{tool.name}</code>
</div>
{tool.description && (
<p>{tool.description}</p>
)}
</li>
))}
</ul>
) : (
<p className="settings-empty">
{t('mcp.custom.noTools')}
</p>
)}
</section>
<>
<p>
{result.dynamicToolsSupported
? t('mcp.custom.dynamicToolsSupported')
: t('mcp.custom.dynamicToolsUnsupported')}
</p>
<section
aria-label={t('mcp.builtin.toolsAriaLabel', {
name: server.name
})}
className="mcp-server-tools"
>
<div className="mcp-server-tools__heading">
<strong>
{result.serverName || server.name}
{result.serverVersion
? ` ${result.serverVersion}`
: ''}
</strong>
<small>
{t('mcp.builtin.toolCount', {
count: result.toolCount
})}
</small>
</div>
{result.tools.length > 0 ? (
<ul>
{result.tools.map((tool) => (
<li key={tool.name}>
<div>
<code>{tool.name}</code>
</div>
{tool.description && (
<p>{tool.description}</p>
)}
</li>
))}
</ul>
) : (
<p className="settings-empty">
{t('mcp.custom.noTools')}
</p>
)}
</section>
</>
) : (
<p className="settings-empty">
{t('mcp.custom.testHelp')}
+23
View File
@@ -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: '团队工具服务 工具' })
@@ -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',
@@ -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: '测试',
+5
View File
@@ -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(