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()))
}