feat: support dynamic MCP tool loading
This commit is contained in:
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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({
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user