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
@@ -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),