feat: support dynamic MCP tool loading
This commit is contained in:
@@ -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