Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { CancellationToken } from '../../../../../base/common/cancellation.js';
8
import { observableValue } from '../../../../../base/common/observable.js';
9
import { URI } from '../../../../../base/common/uri.js';
10
import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';
11
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
12
import { ContributionEnablementState } from '../../../chat/common/enablement.js';
13
import { NullLogService } from '../../../../../platform/log/common/log.js';
14
import { IMcpGatewayServerDescriptor } from '../../../../../platform/mcp/common/mcpGateway.js';
15
import { MCP } from '../../common/modelContextProtocol.js';
16
import { McpGatewayToolBrokerChannel } from '../../common/mcpGatewayToolBrokerChannel.js';
17
import { IMcpIcons, IMcpServer, IMcpTool, IMcpToolCallContext, McpConnectionState, McpServerCacheState, McpToolVisibility } from '../../common/mcpTypes.js';
18
import { TestMcpService } from './testMcpService.js';
19
20
suite('McpGatewayToolBrokerChannel', () => {
21
ensureNoDisposablesAreLeakedInTestSuite();
22
23
test('lists model-visible tools for a specific server', async () => {
24
const mcpService = new TestMcpService();
25
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
26
27
const serverA = createServer('collectionA', 'serverA', [
28
createTool('mcp_serverA_echo', async () => ({ content: [{ type: 'text', text: 'A' }] })),
29
createTool('app-only', async () => ({ content: [{ type: 'text', text: 'A2' }] }), McpToolVisibility.App),
30
]);
31
const serverB = createServer('collectionB', 'serverB', [
32
createTool('mcp_serverB_echo', async () => ({ content: [{ type: 'text', text: 'B' }] })),
33
]);
34
35
mcpService.servers.set([serverA, serverB], undefined);
36
37
const resultA = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
38
assert.deepStrictEqual(resultA.map(t => t.name), ['mcp_serverA_echo']);
39
40
const resultB = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverB' });
41
assert.deepStrictEqual(resultB.map(t => t.name), ['mcp_serverB_echo']);
42
43
channel.dispose();
44
});
45
46
test('routes tool calls to specific server', async () => {
47
const mcpService = new TestMcpService();
48
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
49
50
const invoked: string[] = [];
51
const serverA = createServer('collectionA', 'serverA', [
52
createTool('mcp_serverA_echo', async args => {
53
invoked.push(`A:${String(args.name)}`);
54
return { content: [{ type: 'text', text: 'from A' }] };
55
}),
56
]);
57
const serverB = createServer('collectionB', 'serverB', [
58
createTool('mcp_serverB_echo', async args => {
59
invoked.push(`B:${String(args.name)}`);
60
return { content: [{ type: 'text', text: 'from B' }] };
61
}),
62
]);
63
64
mcpService.servers.set([serverA, serverB], undefined);
65
66
const resultA = await channel.call<MCP.CallToolResult>(undefined, 'callToolForServer', {
67
serverId: 'serverA',
68
name: 'mcp_serverA_echo',
69
args: { name: 'one' },
70
});
71
const resultB = await channel.call<MCP.CallToolResult>(undefined, 'callToolForServer', {
72
serverId: 'serverB',
73
name: 'mcp_serverB_echo',
74
args: { name: 'two' },
75
});
76
77
assert.deepStrictEqual(invoked, ['A:one', 'B:two']);
78
assert.strictEqual((resultA.content[0] as MCP.TextContent).text, 'from A');
79
assert.strictEqual((resultB.content[0] as MCP.TextContent).text, 'from B');
80
81
channel.dispose();
82
});
83
84
test('emits onDidChangeTools when tool lists change', async () => {
85
const mcpService = new TestMcpService();
86
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
87
const server = createServer('collectionA', 'serverA', [
88
createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] })),
89
]);
90
91
mcpService.servers.set([server], undefined);
92
93
let events = 0;
94
const disposable = channel.listen<void>(undefined, 'onDidChangeTools')(() => {
95
events++;
96
});
97
98
server.toolsValue.set([
99
createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] })),
100
createTool('echo2', async () => ({ content: [{ type: 'text', text: 'A2' }] })),
101
], undefined);
102
103
assert.ok(events >= 1);
104
105
disposable.dispose();
106
channel.dispose();
107
});
108
109
test('does not start server when cache state is live', async () => {
110
const mcpService = new TestMcpService();
111
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
112
113
const server = createServer(
114
'collectionA',
115
'serverA',
116
[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],
117
McpServerCacheState.Live,
118
);
119
120
mcpService.servers.set([server], undefined);
121
await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
122
123
assert.strictEqual(server.startCalls, 0);
124
channel.dispose();
125
});
126
127
test('starts server when cache state is unknown', async () => {
128
const mcpService = new TestMcpService();
129
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
130
131
const server = createServer(
132
'collectionA',
133
'serverA',
134
[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],
135
McpServerCacheState.Unknown,
136
);
137
138
mcpService.servers.set([server], undefined);
139
const tools = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
140
141
// Server started during the grace period; tools are now available.
142
assert.strictEqual(server.startCalls, 1);
143
assert.deepStrictEqual(tools.map(t => t.name), ['echo']);
144
channel.dispose();
145
});
146
147
test('starts server and waits within grace period when cache state is outdated', async () => {
148
const mcpService = new TestMcpService();
149
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
150
151
const server = createServer(
152
'collectionA',
153
'serverA',
154
[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],
155
McpServerCacheState.Outdated,
156
);
157
158
mcpService.servers.set([server], undefined);
159
const tools = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
160
161
// Outdated server gets the same grace period as Unknown — started and tools returned.
162
assert.strictEqual(server.startCalls, 1);
163
assert.deepStrictEqual(tools.map(t => t.name), ['echo']);
164
channel.dispose();
165
});
166
167
test('returns empty tools and does not re-wait if server does not start within grace period', () => {
168
return runWithFakedTimers({ useFakeTimers: true }, async () => {
169
const mcpService = new TestMcpService();
170
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService(), 100);
171
172
const server = createNeverStartingServer(
173
'collectionA',
174
'serverA',
175
[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],
176
);
177
178
mcpService.servers.set([server], undefined);
179
180
// First call: waits up to the grace period, server never starts → empty result.
181
const tools = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
182
assert.deepStrictEqual(tools, []);
183
184
// Second call: grace-period promise already resolved; returns immediately without re-waiting.
185
const tools2 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
186
assert.deepStrictEqual(tools2, []);
187
188
channel.dispose();
189
});
190
});
191
192
test('invalidates stale grace entry when cacheState regresses to Unknown after timeout', () => {
193
return runWithFakedTimers({ useFakeTimers: true }, async () => {
194
const mcpService = new TestMcpService();
195
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService(), 100);
196
197
const server = createNeverStartingServer(
198
'collectionA',
199
'serverA',
200
[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],
201
);
202
203
mcpService.servers.set([server], undefined);
204
205
// First call: grace period elapses, server never starts → empty.
206
const tools1 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
207
assert.deepStrictEqual(tools1, []);
208
assert.strictEqual(server.startCalls, 1);
209
210
// Simulate a cache reset: server goes back to Unknown.
211
server.cacheStateValue.set(McpServerCacheState.Unknown, undefined);
212
213
// Make the server succeed this time.
214
server.startBehavior = 'succeed';
215
216
// Second call: stale grace entry should be discarded, a new grace race starts,
217
// and the server successfully starts → tools returned.
218
const tools2 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
219
assert.deepStrictEqual(tools2.map(t => t.name), ['echo']);
220
assert.strictEqual(server.startCalls, 2);
221
222
channel.dispose();
223
});
224
});
225
226
test('does not invalidate grace entry when cacheState is not Unknown/Outdated', () => {
227
return runWithFakedTimers({ useFakeTimers: true }, async () => {
228
const mcpService = new TestMcpService();
229
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService(), 100);
230
231
const server = createServer(
232
'collectionA',
233
'serverA',
234
[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],
235
McpServerCacheState.Unknown,
236
);
237
238
mcpService.servers.set([server], undefined);
239
240
// First call: server starts successfully during grace period.
241
const tools1 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
242
assert.deepStrictEqual(tools1.map(t => t.name), ['echo']);
243
assert.strictEqual(server.startCalls, 1);
244
245
// Second call: cacheState is now Live (server started), grace entry should NOT
246
// be invalidated, so no additional start call is made.
247
const tools2 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
248
assert.deepStrictEqual(tools2.map(t => t.name), ['echo']);
249
assert.strictEqual(server.startCalls, 1);
250
251
channel.dispose();
252
});
253
});
254
255
test('listServers returns all servers regardless of cache state', async () => {
256
const mcpService = new TestMcpService();
257
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
258
259
const liveServer = createServer('collectionA', 'serverA', [], McpServerCacheState.Live);
260
const unknownServer = createServer('collectionB', 'serverB', [], McpServerCacheState.Unknown);
261
262
mcpService.servers.set([liveServer, unknownServer], undefined);
263
264
const servers = await channel.call<readonly IMcpGatewayServerDescriptor[]>(undefined, 'listServers');
265
assert.deepStrictEqual(servers, [
266
{ id: 'serverA', label: 'serverA' },
267
{ id: 'serverB', label: 'serverB' },
268
]);
269
270
channel.dispose();
271
});
272
273
test('forwards chatSessionResource as tool call context', async () => {
274
const mcpService = new TestMcpService();
275
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
276
277
const receivedContexts: (IMcpToolCallContext | undefined)[] = [];
278
const server = createServer('collectionA', 'serverA', [
279
createToolWithContextCapture('echo', receivedContexts, async () => ({ content: [{ type: 'text', text: 'ok' }] })),
280
]);
281
282
mcpService.servers.set([server], undefined);
283
284
const sessionUri = 'vscode-chat-session://test/session-123';
285
await channel.call<MCP.CallToolResult>(undefined, 'callToolForServer', {
286
serverId: 'serverA',
287
name: 'echo',
288
args: { input: 'hello' },
289
chatSessionResource: sessionUri,
290
});
291
292
assert.strictEqual(receivedContexts.length, 1);
293
assert.ok(receivedContexts[0]);
294
assert.strictEqual(receivedContexts[0]!.chatSessionResource!.toString(), URI.parse(sessionUri).toString());
295
296
channel.dispose();
297
});
298
299
test('passes undefined context when chatSessionResource is omitted', async () => {
300
const mcpService = new TestMcpService();
301
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
302
303
const receivedContexts: (IMcpToolCallContext | undefined)[] = [];
304
const server = createServer('collectionA', 'serverA', [
305
createToolWithContextCapture('echo', receivedContexts, async () => ({ content: [{ type: 'text', text: 'ok' }] })),
306
]);
307
308
mcpService.servers.set([server], undefined);
309
310
await channel.call<MCP.CallToolResult>(undefined, 'callToolForServer', {
311
serverId: 'serverA',
312
name: 'echo',
313
args: { input: 'hello' },
314
});
315
316
assert.strictEqual(receivedContexts.length, 1);
317
assert.strictEqual(receivedContexts[0], undefined);
318
319
channel.dispose();
320
});
321
322
test('emits onDidChangeServers with descriptors when servers change', () => {
323
const mcpService = new TestMcpService();
324
const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());
325
const serverA = createServer('collectionA', 'serverA', []);
326
327
mcpService.servers.set([serverA], undefined);
328
329
const received: (readonly IMcpGatewayServerDescriptor[])[] = [];
330
const disposable = channel.listen<readonly IMcpGatewayServerDescriptor[]>(undefined, 'onDidChangeServers')(e => {
331
received.push(e);
332
});
333
334
// Add a second server
335
const serverB = createServer('collectionB', 'serverB', []);
336
mcpService.servers.set([serverA, serverB], undefined);
337
338
assert.strictEqual(received.length, 1);
339
assert.deepStrictEqual(received[0], [
340
{ id: 'serverA', label: 'serverA' },
341
{ id: 'serverB', label: 'serverB' },
342
]);
343
344
// Remove the first server
345
mcpService.servers.set([serverB], undefined);
346
347
assert.strictEqual(received.length, 2);
348
assert.deepStrictEqual(received[1], [
349
{ id: 'serverB', label: 'serverB' },
350
]);
351
352
disposable.dispose();
353
channel.dispose();
354
});
355
});
356
357
function createServer(
358
collectionId: string,
359
definitionId: string,
360
initialTools: readonly IMcpTool[],
361
initialCacheState: McpServerCacheState = McpServerCacheState.Live,
362
): IMcpServer & { toolsValue: ReturnType<typeof observableValue<readonly IMcpTool[]>>; startCalls: number } {
363
const owner = {};
364
const tools = observableValue<readonly IMcpTool[]>(owner, initialTools);
365
const connectionState = observableValue<McpConnectionState>(owner, { state: McpConnectionState.Kind.Running });
366
const cacheState = observableValue<McpServerCacheState>(owner, initialCacheState);
367
let startCalls = 0;
368
369
return {
370
collection: { id: collectionId, label: collectionId, order: 0 },
371
definition: { id: definitionId, label: definitionId },
372
connection: observableValue(owner, undefined),
373
connectionState,
374
enablement: observableValue(owner, ContributionEnablementState.EnabledProfile),
375
serverMetadata: observableValue(owner, undefined),
376
readDefinitions: () => observableValue(owner, { server: undefined, collection: undefined }),
377
showOutput: async () => { },
378
start: async () => {
379
startCalls++;
380
cacheState.set(McpServerCacheState.Live, undefined);
381
return { state: McpConnectionState.Kind.Running };
382
},
383
stop: async () => { },
384
cacheState,
385
tools,
386
prompts: observableValue(owner, []),
387
capabilities: observableValue(owner, undefined),
388
resources: () => (async function* () { })(),
389
resourceTemplates: async () => [],
390
dispose: () => { },
391
toolsValue: tools,
392
get startCalls() { return startCalls; },
393
};
394
}
395
396
function createNeverStartingServer(
397
collectionId: string,
398
definitionId: string,
399
initialTools: readonly IMcpTool[],
400
): IMcpServer & { startCalls: number; startBehavior: 'hang' | 'succeed'; cacheStateValue: ReturnType<typeof observableValue<McpServerCacheState>> } {
401
const owner = {};
402
const tools = observableValue<readonly IMcpTool[]>(owner, initialTools);
403
const connectionState = observableValue<McpConnectionState>(owner, { state: McpConnectionState.Kind.Running });
404
const cacheState = observableValue<McpServerCacheState>(owner, McpServerCacheState.Unknown);
405
let startCalls = 0;
406
let startBehavior: 'hang' | 'succeed' = 'hang';
407
408
const result: IMcpServer & { startCalls: number; startBehavior: 'hang' | 'succeed'; cacheStateValue: ReturnType<typeof observableValue<McpServerCacheState>> } = {
409
collection: { id: collectionId, label: collectionId, order: 0 },
410
definition: { id: definitionId, label: definitionId },
411
connection: observableValue(owner, undefined),
412
connectionState,
413
enablement: observableValue(owner, ContributionEnablementState.EnabledProfile),
414
serverMetadata: observableValue(owner, undefined),
415
readDefinitions: () => observableValue(owner, { server: undefined, collection: undefined }),
416
showOutput: async () => { },
417
start: async () => {
418
startCalls++;
419
if (result.startBehavior === 'succeed') {
420
cacheState.set(McpServerCacheState.Live, undefined);
421
return { state: McpConnectionState.Kind.Running };
422
}
423
// Never resolves — simulates a server that hangs on startup.
424
return new Promise<McpConnectionState>(() => { });
425
},
426
stop: async () => { },
427
cacheState,
428
tools,
429
prompts: observableValue(owner, []),
430
capabilities: observableValue(owner, undefined),
431
resources: () => (async function* () { })(),
432
resourceTemplates: async () => [],
433
dispose: () => { },
434
get startCalls() { return startCalls; },
435
get startBehavior() { return startBehavior; },
436
set startBehavior(v) { startBehavior = v; },
437
cacheStateValue: cacheState,
438
};
439
return result;
440
}
441
442
function createToolWithContextCapture(
443
name: string,
444
receivedContexts: (IMcpToolCallContext | undefined)[],
445
call: (params: Record<string, unknown>) => Promise<MCP.CallToolResult>,
446
visibility: McpToolVisibility = McpToolVisibility.Model,
447
): IMcpTool {
448
const definition: MCP.Tool = {
449
name,
450
description: `Tool ${name}`,
451
inputSchema: { type: 'object', properties: { input: { type: 'string' } } },
452
};
453
454
return {
455
id: `tool_${name}`,
456
referenceName: name,
457
icons: {} as IMcpIcons,
458
definition,
459
visibility,
460
uiResourceUri: undefined,
461
call: (params: Record<string, unknown>, context, _token) => {
462
receivedContexts.push(context);
463
return call(params);
464
},
465
callWithProgress: (params: Record<string, unknown>, _progress, context, _token = CancellationToken.None) => {
466
receivedContexts.push(context);
467
return call(params);
468
},
469
};
470
}
471
472
function createTool(name: string, call: (params: Record<string, unknown>) => Promise<MCP.CallToolResult>, visibility: McpToolVisibility = McpToolVisibility.Model): IMcpTool {
473
const definition: MCP.Tool = {
474
name,
475
description: `Tool ${name}`,
476
inputSchema: {
477
type: 'object',
478
properties: {
479
input: { type: 'string' },
480
},
481
},
482
};
483
484
return {
485
id: `tool_${name}`,
486
referenceName: name,
487
icons: {} as IMcpIcons,
488
definition,
489
visibility,
490
uiResourceUri: undefined,
491
call: (params: Record<string, unknown>, _context, _token) => call(params),
492
callWithProgress: (params: Record<string, unknown>, _progress, _context, _token = CancellationToken.None) => call(params),
493
};
494
}
495
496