Path: blob/main/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { CancellationToken } from '../../../../../base/common/cancellation.js';7import { observableValue } from '../../../../../base/common/observable.js';8import { URI } from '../../../../../base/common/uri.js';9import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js';10import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';11import { ContributionEnablementState } from '../../../chat/common/enablement.js';12import { NullLogService } from '../../../../../platform/log/common/log.js';13import { IMcpGatewayServerDescriptor } from '../../../../../platform/mcp/common/mcpGateway.js';14import { MCP } from '../../common/modelContextProtocol.js';15import { McpGatewayToolBrokerChannel } from '../../common/mcpGatewayToolBrokerChannel.js';16import { IMcpIcons, IMcpServer, IMcpTool, IMcpToolCallContext, McpConnectionState, McpServerCacheState, McpToolVisibility } from '../../common/mcpTypes.js';17import { TestMcpService } from './testMcpService.js';1819suite('McpGatewayToolBrokerChannel', () => {20ensureNoDisposablesAreLeakedInTestSuite();2122test('lists model-visible tools for a specific server', async () => {23const mcpService = new TestMcpService();24const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());2526const serverA = createServer('collectionA', 'serverA', [27createTool('mcp_serverA_echo', async () => ({ content: [{ type: 'text', text: 'A' }] })),28createTool('app-only', async () => ({ content: [{ type: 'text', text: 'A2' }] }), McpToolVisibility.App),29]);30const serverB = createServer('collectionB', 'serverB', [31createTool('mcp_serverB_echo', async () => ({ content: [{ type: 'text', text: 'B' }] })),32]);3334mcpService.servers.set([serverA, serverB], undefined);3536const resultA = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });37assert.deepStrictEqual(resultA.map(t => t.name), ['mcp_serverA_echo']);3839const resultB = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverB' });40assert.deepStrictEqual(resultB.map(t => t.name), ['mcp_serverB_echo']);4142channel.dispose();43});4445test('routes tool calls to specific server', async () => {46const mcpService = new TestMcpService();47const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());4849const invoked: string[] = [];50const serverA = createServer('collectionA', 'serverA', [51createTool('mcp_serverA_echo', async args => {52invoked.push(`A:${String(args.name)}`);53return { content: [{ type: 'text', text: 'from A' }] };54}),55]);56const serverB = createServer('collectionB', 'serverB', [57createTool('mcp_serverB_echo', async args => {58invoked.push(`B:${String(args.name)}`);59return { content: [{ type: 'text', text: 'from B' }] };60}),61]);6263mcpService.servers.set([serverA, serverB], undefined);6465const resultA = await channel.call<MCP.CallToolResult>(undefined, 'callToolForServer', {66serverId: 'serverA',67name: 'mcp_serverA_echo',68args: { name: 'one' },69});70const resultB = await channel.call<MCP.CallToolResult>(undefined, 'callToolForServer', {71serverId: 'serverB',72name: 'mcp_serverB_echo',73args: { name: 'two' },74});7576assert.deepStrictEqual(invoked, ['A:one', 'B:two']);77assert.strictEqual((resultA.content[0] as MCP.TextContent).text, 'from A');78assert.strictEqual((resultB.content[0] as MCP.TextContent).text, 'from B');7980channel.dispose();81});8283test('emits onDidChangeTools when tool lists change', async () => {84const mcpService = new TestMcpService();85const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());86const server = createServer('collectionA', 'serverA', [87createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] })),88]);8990mcpService.servers.set([server], undefined);9192let events = 0;93const disposable = channel.listen<void>(undefined, 'onDidChangeTools')(() => {94events++;95});9697server.toolsValue.set([98createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] })),99createTool('echo2', async () => ({ content: [{ type: 'text', text: 'A2' }] })),100], undefined);101102assert.ok(events >= 1);103104disposable.dispose();105channel.dispose();106});107108test('does not start server when cache state is live', async () => {109const mcpService = new TestMcpService();110const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());111112const server = createServer(113'collectionA',114'serverA',115[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],116McpServerCacheState.Live,117);118119mcpService.servers.set([server], undefined);120await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });121122assert.strictEqual(server.startCalls, 0);123channel.dispose();124});125126test('starts server when cache state is unknown', async () => {127const mcpService = new TestMcpService();128const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());129130const server = createServer(131'collectionA',132'serverA',133[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],134McpServerCacheState.Unknown,135);136137mcpService.servers.set([server], undefined);138const tools = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });139140// Server started during the grace period; tools are now available.141assert.strictEqual(server.startCalls, 1);142assert.deepStrictEqual(tools.map(t => t.name), ['echo']);143channel.dispose();144});145146test('starts server and waits within grace period when cache state is outdated', async () => {147const mcpService = new TestMcpService();148const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());149150const server = createServer(151'collectionA',152'serverA',153[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],154McpServerCacheState.Outdated,155);156157mcpService.servers.set([server], undefined);158const tools = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });159160// Outdated server gets the same grace period as Unknown — started and tools returned.161assert.strictEqual(server.startCalls, 1);162assert.deepStrictEqual(tools.map(t => t.name), ['echo']);163channel.dispose();164});165166test('returns empty tools and does not re-wait if server does not start within grace period', () => {167return runWithFakedTimers({ useFakeTimers: true }, async () => {168const mcpService = new TestMcpService();169const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService(), 100);170171const server = createNeverStartingServer(172'collectionA',173'serverA',174[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],175);176177mcpService.servers.set([server], undefined);178179// First call: waits up to the grace period, server never starts → empty result.180const tools = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });181assert.deepStrictEqual(tools, []);182183// Second call: grace-period promise already resolved; returns immediately without re-waiting.184const tools2 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });185assert.deepStrictEqual(tools2, []);186187channel.dispose();188});189});190191test('invalidates stale grace entry when cacheState regresses to Unknown after timeout', () => {192return runWithFakedTimers({ useFakeTimers: true }, async () => {193const mcpService = new TestMcpService();194const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService(), 100);195196const server = createNeverStartingServer(197'collectionA',198'serverA',199[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],200);201202mcpService.servers.set([server], undefined);203204// First call: grace period elapses, server never starts → empty.205const tools1 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });206assert.deepStrictEqual(tools1, []);207assert.strictEqual(server.startCalls, 1);208209// Simulate a cache reset: server goes back to Unknown.210server.cacheStateValue.set(McpServerCacheState.Unknown, undefined);211212// Make the server succeed this time.213server.startBehavior = 'succeed';214215// Second call: stale grace entry should be discarded, a new grace race starts,216// and the server successfully starts → tools returned.217const tools2 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });218assert.deepStrictEqual(tools2.map(t => t.name), ['echo']);219assert.strictEqual(server.startCalls, 2);220221channel.dispose();222});223});224225test('does not invalidate grace entry when cacheState is not Unknown/Outdated', () => {226return runWithFakedTimers({ useFakeTimers: true }, async () => {227const mcpService = new TestMcpService();228const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService(), 100);229230const server = createServer(231'collectionA',232'serverA',233[createTool('echo', async () => ({ content: [{ type: 'text', text: 'A' }] }))],234McpServerCacheState.Unknown,235);236237mcpService.servers.set([server], undefined);238239// First call: server starts successfully during grace period.240const tools1 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });241assert.deepStrictEqual(tools1.map(t => t.name), ['echo']);242assert.strictEqual(server.startCalls, 1);243244// Second call: cacheState is now Live (server started), grace entry should NOT245// be invalidated, so no additional start call is made.246const tools2 = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });247assert.deepStrictEqual(tools2.map(t => t.name), ['echo']);248assert.strictEqual(server.startCalls, 1);249250channel.dispose();251});252});253254test('listServers returns all servers regardless of cache state', async () => {255const mcpService = new TestMcpService();256const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());257258const liveServer = createServer('collectionA', 'serverA', [], McpServerCacheState.Live);259const unknownServer = createServer('collectionB', 'serverB', [], McpServerCacheState.Unknown);260261mcpService.servers.set([liveServer, unknownServer], undefined);262263const servers = await channel.call<readonly IMcpGatewayServerDescriptor[]>(undefined, 'listServers');264assert.deepStrictEqual(servers, [265{ id: 'serverA', label: 'serverA' },266{ id: 'serverB', label: 'serverB' },267]);268269channel.dispose();270});271272test('forwards chatSessionResource as tool call context', async () => {273const mcpService = new TestMcpService();274const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());275276const receivedContexts: (IMcpToolCallContext | undefined)[] = [];277const server = createServer('collectionA', 'serverA', [278createToolWithContextCapture('echo', receivedContexts, async () => ({ content: [{ type: 'text', text: 'ok' }] })),279]);280281mcpService.servers.set([server], undefined);282283const sessionUri = 'vscode-chat-session://test/session-123';284await channel.call<MCP.CallToolResult>(undefined, 'callToolForServer', {285serverId: 'serverA',286name: 'echo',287args: { input: 'hello' },288chatSessionResource: sessionUri,289});290291assert.strictEqual(receivedContexts.length, 1);292assert.ok(receivedContexts[0]);293assert.strictEqual(receivedContexts[0]!.chatSessionResource!.toString(), URI.parse(sessionUri).toString());294295channel.dispose();296});297298test('passes undefined context when chatSessionResource is omitted', async () => {299const mcpService = new TestMcpService();300const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());301302const receivedContexts: (IMcpToolCallContext | undefined)[] = [];303const server = createServer('collectionA', 'serverA', [304createToolWithContextCapture('echo', receivedContexts, async () => ({ content: [{ type: 'text', text: 'ok' }] })),305]);306307mcpService.servers.set([server], undefined);308309await channel.call<MCP.CallToolResult>(undefined, 'callToolForServer', {310serverId: 'serverA',311name: 'echo',312args: { input: 'hello' },313});314315assert.strictEqual(receivedContexts.length, 1);316assert.strictEqual(receivedContexts[0], undefined);317318channel.dispose();319});320321test('emits onDidChangeServers with descriptors when servers change', () => {322const mcpService = new TestMcpService();323const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService());324const serverA = createServer('collectionA', 'serverA', []);325326mcpService.servers.set([serverA], undefined);327328const received: (readonly IMcpGatewayServerDescriptor[])[] = [];329const disposable = channel.listen<readonly IMcpGatewayServerDescriptor[]>(undefined, 'onDidChangeServers')(e => {330received.push(e);331});332333// Add a second server334const serverB = createServer('collectionB', 'serverB', []);335mcpService.servers.set([serverA, serverB], undefined);336337assert.strictEqual(received.length, 1);338assert.deepStrictEqual(received[0], [339{ id: 'serverA', label: 'serverA' },340{ id: 'serverB', label: 'serverB' },341]);342343// Remove the first server344mcpService.servers.set([serverB], undefined);345346assert.strictEqual(received.length, 2);347assert.deepStrictEqual(received[1], [348{ id: 'serverB', label: 'serverB' },349]);350351disposable.dispose();352channel.dispose();353});354});355356function createServer(357collectionId: string,358definitionId: string,359initialTools: readonly IMcpTool[],360initialCacheState: McpServerCacheState = McpServerCacheState.Live,361): IMcpServer & { toolsValue: ReturnType<typeof observableValue<readonly IMcpTool[]>>; startCalls: number } {362const owner = {};363const tools = observableValue<readonly IMcpTool[]>(owner, initialTools);364const connectionState = observableValue<McpConnectionState>(owner, { state: McpConnectionState.Kind.Running });365const cacheState = observableValue<McpServerCacheState>(owner, initialCacheState);366let startCalls = 0;367368return {369collection: { id: collectionId, label: collectionId, order: 0 },370definition: { id: definitionId, label: definitionId },371connection: observableValue(owner, undefined),372connectionState,373enablement: observableValue(owner, ContributionEnablementState.EnabledProfile),374serverMetadata: observableValue(owner, undefined),375readDefinitions: () => observableValue(owner, { server: undefined, collection: undefined }),376showOutput: async () => { },377start: async () => {378startCalls++;379cacheState.set(McpServerCacheState.Live, undefined);380return { state: McpConnectionState.Kind.Running };381},382stop: async () => { },383cacheState,384tools,385prompts: observableValue(owner, []),386capabilities: observableValue(owner, undefined),387resources: () => (async function* () { })(),388resourceTemplates: async () => [],389dispose: () => { },390toolsValue: tools,391get startCalls() { return startCalls; },392};393}394395function createNeverStartingServer(396collectionId: string,397definitionId: string,398initialTools: readonly IMcpTool[],399): IMcpServer & { startCalls: number; startBehavior: 'hang' | 'succeed'; cacheStateValue: ReturnType<typeof observableValue<McpServerCacheState>> } {400const owner = {};401const tools = observableValue<readonly IMcpTool[]>(owner, initialTools);402const connectionState = observableValue<McpConnectionState>(owner, { state: McpConnectionState.Kind.Running });403const cacheState = observableValue<McpServerCacheState>(owner, McpServerCacheState.Unknown);404let startCalls = 0;405let startBehavior: 'hang' | 'succeed' = 'hang';406407const result: IMcpServer & { startCalls: number; startBehavior: 'hang' | 'succeed'; cacheStateValue: ReturnType<typeof observableValue<McpServerCacheState>> } = {408collection: { id: collectionId, label: collectionId, order: 0 },409definition: { id: definitionId, label: definitionId },410connection: observableValue(owner, undefined),411connectionState,412enablement: observableValue(owner, ContributionEnablementState.EnabledProfile),413serverMetadata: observableValue(owner, undefined),414readDefinitions: () => observableValue(owner, { server: undefined, collection: undefined }),415showOutput: async () => { },416start: async () => {417startCalls++;418if (result.startBehavior === 'succeed') {419cacheState.set(McpServerCacheState.Live, undefined);420return { state: McpConnectionState.Kind.Running };421}422// Never resolves — simulates a server that hangs on startup.423return new Promise<McpConnectionState>(() => { });424},425stop: async () => { },426cacheState,427tools,428prompts: observableValue(owner, []),429capabilities: observableValue(owner, undefined),430resources: () => (async function* () { })(),431resourceTemplates: async () => [],432dispose: () => { },433get startCalls() { return startCalls; },434get startBehavior() { return startBehavior; },435set startBehavior(v) { startBehavior = v; },436cacheStateValue: cacheState,437};438return result;439}440441function createToolWithContextCapture(442name: string,443receivedContexts: (IMcpToolCallContext | undefined)[],444call: (params: Record<string, unknown>) => Promise<MCP.CallToolResult>,445visibility: McpToolVisibility = McpToolVisibility.Model,446): IMcpTool {447const definition: MCP.Tool = {448name,449description: `Tool ${name}`,450inputSchema: { type: 'object', properties: { input: { type: 'string' } } },451};452453return {454id: `tool_${name}`,455referenceName: name,456icons: {} as IMcpIcons,457definition,458visibility,459uiResourceUri: undefined,460call: (params: Record<string, unknown>, context, _token) => {461receivedContexts.push(context);462return call(params);463},464callWithProgress: (params: Record<string, unknown>, _progress, context, _token = CancellationToken.None) => {465receivedContexts.push(context);466return call(params);467},468};469}470471function createTool(name: string, call: (params: Record<string, unknown>) => Promise<MCP.CallToolResult>, visibility: McpToolVisibility = McpToolVisibility.Model): IMcpTool {472const definition: MCP.Tool = {473name,474description: `Tool ${name}`,475inputSchema: {476type: 'object',477properties: {478input: { type: 'string' },479},480},481};482483return {484id: `tool_${name}`,485referenceName: name,486icons: {} as IMcpIcons,487definition,488visibility,489uiResourceUri: undefined,490call: (params: Record<string, unknown>, _context, _token) => call(params),491callWithProgress: (params: Record<string, unknown>, _progress, _context, _token = CancellationToken.None) => call(params),492};493}494495496