diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 37e1901f0a7..d62aa30b912 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -317,6 +317,7 @@ const _allApiProposals = { }, mcpServerDefinitions: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts', + version: 1 }, mcpToolDefinitions: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.mcpToolDefinitions.d.ts', diff --git a/src/vs/platform/mcp/common/mcpGateway.ts b/src/vs/platform/mcp/common/mcpGateway.ts index 27fd5426922..ccc91ac7f0a 100644 --- a/src/vs/platform/mcp/common/mcpGateway.ts +++ b/src/vs/platform/mcp/common/mcpGateway.ts @@ -13,39 +13,61 @@ export const IMcpGatewayService = createDecorator('IMcpGatew export const McpGatewayChannelName = 'mcpGateway'; export const McpGatewayToolBrokerChannelName = 'mcpGatewayToolBroker'; -export interface IGatewayCallToolResult { - result: MCP.CallToolResult; - serverIndex: number; -} - -export interface IGatewayServerResources { - serverIndex: number; - resources: readonly MCP.Resource[]; -} - -export interface IGatewayServerResourceTemplates { - serverIndex: number; - resourceTemplates: readonly MCP.ResourceTemplate[]; -} - -export interface IMcpGatewayToolInvoker { - readonly onDidChangeTools: Event; - readonly onDidChangeResources: Event; - listTools(): Promise; - callTool(name: string, args: Record): Promise; - listResources(): Promise; - readResource(serverIndex: number, uri: string): Promise; - listResourceTemplates(): Promise; +/** + * Descriptor for an MCP server known to the gateway. + */ +export interface IMcpGatewayServerDescriptor { + readonly id: string; + readonly label: string; } /** - * Result of creating an MCP gateway. + * A single server entry exposed by the gateway. */ -export interface IMcpGatewayInfo { - /** - * The address of the HTTP endpoint for this gateway. - */ +export interface IMcpGatewayServerInfo { + readonly label: string; readonly address: URI; +} + +/** + * Per-server tool invoker used by a single gateway route/session. + * All methods operate on the specific server this invoker is bound to. + */ +export interface IMcpGatewaySingleServerInvoker { + readonly onDidChangeTools: Event; + readonly onDidChangeResources: Event; + listTools(): Promise; + callTool(name: string, args: Record): Promise; + listResources(): Promise; + readResource(uri: string): Promise; + listResourceTemplates(): Promise; +} + +/** + * Aggregating tool invoker that provides per-server operations and + * server lifecycle tracking. Used by the gateway service to create + * and manage per-server routes. + */ +export interface IMcpGatewayToolInvoker { + readonly onDidChangeServers: Event; + readonly onDidChangeTools: Event; + readonly onDidChangeResources: Event; + listServers(): readonly IMcpGatewayServerDescriptor[]; + listToolsForServer(serverId: string): Promise; + callToolForServer(serverId: string, name: string, args: Record): Promise; + listResourcesForServer(serverId: string): Promise; + readResourceForServer(serverId: string, uri: string): Promise; + listResourceTemplatesForServer(serverId: string): Promise; +} + +/** + * Serializable result of creating an MCP gateway (safe for IPC). + */ +export interface IMcpGatewayDto { + /** + * The servers currently exposed by this gateway. + */ + readonly servers: readonly IMcpGatewayServerInfo[]; /** * The unique identifier for this gateway, used for disposal. @@ -53,6 +75,16 @@ export interface IMcpGatewayInfo { readonly gatewayId: string; } +/** + * Result of creating an MCP gateway (in-process, includes event). + */ +export interface IMcpGatewayInfo extends IMcpGatewayDto { + /** + * Event that fires when the set of servers changes. + */ + readonly onDidChangeServers: Event; +} + /** * Service that manages MCP gateway HTTP endpoints in the main process (or remote server). * diff --git a/src/vs/platform/mcp/node/mcpGatewayChannel.ts b/src/vs/platform/mcp/node/mcpGatewayChannel.ts index 78d0e93a3f0..9507f2fd7eb 100644 --- a/src/vs/platform/mcp/node/mcpGatewayChannel.ts +++ b/src/vs/platform/mcp/node/mcpGatewayChannel.ts @@ -3,11 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; import { IPCServer, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; import { ILoggerService } from '../../log/common/log.js'; -import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates, IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; +import { IMcpGatewayServerDescriptor, IMcpGatewayServerInfo, IMcpGatewayService, McpGatewayToolBrokerChannelName } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; /** @@ -18,6 +18,11 @@ import { MCP } from '../common/modelContextProtocol.js'; */ export class McpGatewayChannel extends Disposable implements IServerChannel { + private readonly _onDidChangeGatewayServers = this._register(new Emitter<{ gatewayId: string; servers: readonly IMcpGatewayServerInfo[] }>()); + private readonly _gatewayDisposables = this._register(new DisposableMap()); + /** Tracks which gateways belong to which client for cleanup on disconnect */ + private readonly _clientGateways = new Map>(); + constructor( private readonly _ipcServer: IPCServer, @IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService, @@ -27,11 +32,23 @@ export class McpGatewayChannel extends Disposable implements IServerCh this._register(_ipcServer.onDidRemoveConnection(c => { this._loggerService.getLogger('mcpGateway')?.info(`[McpGateway][Channel] Client disconnected: ${c.ctx}, cleaning up gateways`); mcpGatewayService.disposeGatewaysForClient(c.ctx); + + // Clean up per-gateway change-event forwarders for this client + const gatewaysForClient = this._clientGateways.get(c.ctx); + if (gatewaysForClient) { + for (const gatewayId of gatewaysForClient) { + this._gatewayDisposables.deleteAndDispose(gatewayId); + } + this._clientGateways.delete(c.ctx); + } })); } - listen(_ctx: TContext, _event: string): Event { - throw new Error('Invalid listen'); + listen(_ctx: TContext, event: string): Event { + if (event === 'onDidChangeGatewayServers') { + return this._onDidChangeGatewayServers.event as Event; + } + throw new Error(`Invalid listen: ${event}`); } async call(ctx: TContext, command: string, args?: unknown): Promise { @@ -41,21 +58,59 @@ export class McpGatewayChannel extends Disposable implements IServerCh switch (command) { case 'createGateway': { const brokerChannel = ipcChannelForContext(this._ipcServer, ctx); + + // Fetch initial server list before creating the gateway (IPC is async, but the invoker interface is sync) + let currentServers = await brokerChannel.call('listServers'); + const onDidChangeServersListener = brokerChannel.listen('onDidChangeServers'); + const result = await this.mcpGatewayService.createGateway(ctx, { + onDidChangeServers: Event.map(onDidChangeServersListener, servers => { + currentServers = servers; + return servers; + }), onDidChangeTools: brokerChannel.listen('onDidChangeTools'), onDidChangeResources: brokerChannel.listen('onDidChangeResources'), - listTools: () => brokerChannel.call('listTools'), - callTool: (name, callArgs) => brokerChannel.call('callTool', { name, args: callArgs }), - listResources: () => brokerChannel.call('listResources'), - readResource: (serverIndex, uri) => brokerChannel.call('readResource', { serverIndex, uri }), - listResourceTemplates: () => brokerChannel.call('listResourceTemplates'), + listServers: () => currentServers, + listToolsForServer: serverId => brokerChannel.call('listToolsForServer', { serverId }), + callToolForServer: (serverId, name, callArgs) => brokerChannel.call('callToolForServer', { serverId, name, args: callArgs }), + listResourcesForServer: serverId => brokerChannel.call('listResourcesForServer', { serverId }), + readResourceForServer: (serverId, uri) => brokerChannel.call('readResourceForServer', { serverId, uri }), + listResourceTemplatesForServer: serverId => brokerChannel.call('listResourceTemplatesForServer', { serverId }), }); - logger?.info(`[McpGateway][Channel] Gateway created: ${result.gatewayId} for client ${ctx}`); - return result as T; + // Forward server change events via IPC + const gatewayStore = new DisposableStore(); + gatewayStore.add(result.onDidChangeServers(servers => { + this._onDidChangeGatewayServers.fire({ gatewayId: result.gatewayId, servers }); + })); + this._gatewayDisposables.set(result.gatewayId, gatewayStore); + + // Track client → gateway for disconnect cleanup + let gatewaysForClient = this._clientGateways.get(ctx); + if (!gatewaysForClient) { + gatewaysForClient = new Set(); + this._clientGateways.set(ctx, gatewaysForClient); + } + gatewaysForClient.add(result.gatewayId); + + logger?.info(`[McpGateway][Channel] Gateway created: ${result.gatewayId} with ${result.servers.length} server(s) for client ${ctx}`); + // eslint-disable-next-line local/code-no-dangerous-type-assertions + return { gatewayId: result.gatewayId, servers: result.servers } as T; } case 'disposeGateway': { - logger?.info(`[McpGateway][Channel] Disposing gateway: ${args as string} for client ${ctx}`); - await this.mcpGatewayService.disposeGateway(args as string); + const gatewayId = args as string; + logger?.info(`[McpGateway][Channel] Disposing gateway: ${gatewayId} for client ${ctx}`); + this._gatewayDisposables.deleteAndDispose(gatewayId); + + // Remove from client tracking + const gatewaysForClient = this._clientGateways.get(ctx); + if (gatewaysForClient) { + gatewaysForClient.delete(gatewayId); + if (gatewaysForClient.size === 0) { + this._clientGateways.delete(ctx); + } + } + + await this.mcpGatewayService.disposeGateway(gatewayId); return undefined as T; } } diff --git a/src/vs/platform/mcp/node/mcpGatewayService.ts b/src/vs/platform/mcp/node/mcpGatewayService.ts index 6131c7677da..e00be3def1b 100644 --- a/src/vs/platform/mcp/node/mcpGatewayService.ts +++ b/src/vs/platform/mcp/node/mcpGatewayService.ts @@ -5,12 +5,13 @@ import type * as http from 'http'; import { DeferredPromise } from '../../../base/common/async.js'; +import { Emitter } from '../../../base/common/event.js'; import { JsonRpcMessage, JsonRpcProtocol } from '../../../base/common/jsonRpcProtocol.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogger, ILoggerService } from '../../log/common/log.js'; -import { IMcpGatewayInfo, IMcpGatewayService, IMcpGatewayToolInvoker } from '../common/mcpGateway.js'; +import { IMcpGatewayInfo, IMcpGatewayServerDescriptor, IMcpGatewayServerInfo, IMcpGatewayService, IMcpGatewaySingleServerInvoker, IMcpGatewayToolInvoker } from '../common/mcpGateway.js'; import { isInitializeMessage, McpGatewaySession } from './mcpGatewaySession.js'; /** @@ -24,9 +25,16 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService private _server: http.Server | undefined; private _port: number | undefined; - private readonly _gateways = new Map(); + /** All active routes keyed by their route UUID */ + private readonly _routes = new Map(); + /** Maps gatewayId → set of route UUIDs belonging to that gateway */ + private readonly _gatewayRoutes = new Map>(); + /** Maps gatewayId → serverId → routeId for reverse lookup */ + private readonly _gatewayServerRoutes = new Map>(); /** Maps gatewayId to clientId for tracking ownership */ private readonly _gatewayToClient = new Map(); + /** Per-gateway disposables (e.g. event listeners) */ + private readonly _gatewayDisposables = new Map(); private _serverStartPromise: Promise | undefined; private readonly _logger: ILogger; @@ -46,48 +54,185 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService throw new Error('[McpGatewayService] Server failed to start, port is undefined'); } - // Generate a secure random ID for the gateway route - const gatewayId = generateUuid(); - - // Create the gateway route if (!toolInvoker) { throw new Error('[McpGatewayService] Tool invoker is required to create gateway'); } - const gateway = new McpGatewayRoute(gatewayId, this._logger, toolInvoker); - this._gateways.set(gatewayId, gateway); - this._logger.info(`[McpGatewayService] Active gateways: ${this._gateways.size}`); + const gatewayId = generateUuid(); + const routeIds = new Set(); + const serverRouteMap = new Map(); + this._gatewayRoutes.set(gatewayId, routeIds); + this._gatewayServerRoutes.set(gatewayId, serverRouteMap); - // Track client ownership if clientId provided (for cleanup on disconnect) - if (clientId) { - this._gatewayToClient.set(gatewayId, clientId); - this._logger.info(`[McpGatewayService] Created gateway at http://127.0.0.1:${this._port}/gateway/${gatewayId} for client ${clientId}`); - } else { - this._logger.warn(`[McpGatewayService] Created gateway without client tracking at http://127.0.0.1:${this._port}/gateway/${gatewayId}`); + const disposables = new DisposableStore(); + this._gatewayDisposables.set(gatewayId, disposables); + + try { + // Create initial server routes + const serverDescriptors = toolInvoker.listServers(); + const servers: IMcpGatewayServerInfo[] = []; + for (const descriptor of serverDescriptors) { + const serverInfo = this._createRouteForServer(gatewayId, descriptor.id, descriptor.label, toolInvoker, routeIds, serverRouteMap); + servers.push(serverInfo); + } + + // Track client ownership + if (clientId) { + this._gatewayToClient.set(gatewayId, clientId); + this._logger.info(`[McpGatewayService] Created gateway ${gatewayId} with ${servers.length} server(s) for client ${clientId}`); + } else { + this._logger.warn(`[McpGatewayService] Created gateway ${gatewayId} with ${servers.length} server(s) without client tracking`); + } + + // Listen for server changes to dynamically add/remove routes + const onDidChangeServers = disposables.add(new Emitter()); + disposables.add(toolInvoker.onDidChangeServers(newDescriptors => { + this._refreshGatewayServers(gatewayId, newDescriptors, toolInvoker, routeIds, serverRouteMap, onDidChangeServers); + })); + + return { + servers, + onDidChangeServers: onDidChangeServers.event, + gatewayId, + }; + } catch (error) { + // Clean up partially-created state on failure + this._cleanupGateway(gatewayId); + throw error; + } + } + + private _refreshGatewayServers( + gatewayId: string, + newDescriptors: readonly IMcpGatewayServerDescriptor[], + toolInvoker: IMcpGatewayToolInvoker, + routeIds: Set, + serverRouteMap: Map, + onDidChangeServers: Emitter, + ): void { + // Bail out if the gateway has been disposed + if (!this._gatewayRoutes.has(gatewayId)) { + return; } - const address = URI.parse(`http://127.0.0.1:${this._port}/gateway/${gatewayId}`); + const newServerIds = new Set(newDescriptors.map(d => d.id)); + const existingServerIds = new Set(serverRouteMap.keys()); - return { - address, - gatewayId, + // Remove routes for servers that are gone + for (const serverId of existingServerIds) { + if (!newServerIds.has(serverId)) { + const routeId = serverRouteMap.get(serverId); + if (routeId) { + this._disposeRoute(routeId); + routeIds.delete(routeId); + serverRouteMap.delete(serverId); + } + } + } + + // Add routes for new servers, and update labels for existing ones. + for (const descriptor of newDescriptors) { + if (!existingServerIds.has(descriptor.id)) { + this._createRouteForServer(gatewayId, descriptor.id, descriptor.label, toolInvoker, routeIds, serverRouteMap); + continue; + } + + const routeId = serverRouteMap.get(descriptor.id); + const route = routeId ? this._routes.get(routeId) : undefined; + if (route && route.label !== descriptor.label) { + route.label = descriptor.label; + } + } + + const updatedServers = this._getGatewayServers(gatewayId); + this._logger.info(`[McpGatewayService] Gateway ${gatewayId} servers changed: ${updatedServers.length} server(s)`); + onDidChangeServers.fire(updatedServers); + } + + private _cleanupGateway(gatewayId: string): void { + const routeIds = this._gatewayRoutes.get(gatewayId); + if (routeIds) { + for (const routeId of routeIds) { + this._disposeRoute(routeId); + } + } + this._gatewayRoutes.delete(gatewayId); + this._gatewayServerRoutes.delete(gatewayId); + this._gatewayToClient.delete(gatewayId); + this._gatewayDisposables.get(gatewayId)?.dispose(); + this._gatewayDisposables.delete(gatewayId); + } + + private _createRouteForServer( + gatewayId: string, + serverId: string, + label: string, + toolInvoker: IMcpGatewayToolInvoker, + routeIds: Set, + serverRouteMap: Map, + ): IMcpGatewayServerInfo { + const routeId = generateUuid(); + + // Create a single-server invoker that delegates to the aggregating invoker + const singleServerInvoker: IMcpGatewaySingleServerInvoker = { + onDidChangeTools: toolInvoker.onDidChangeTools, + onDidChangeResources: toolInvoker.onDidChangeResources, + listTools: () => toolInvoker.listToolsForServer(serverId), + callTool: (name, args) => toolInvoker.callToolForServer(serverId, name, args), + listResources: () => toolInvoker.listResourcesForServer(serverId), + readResource: uri => toolInvoker.readResourceForServer(serverId, uri), + listResourceTemplates: () => toolInvoker.listResourceTemplatesForServer(serverId), }; + + const route = new McpGatewayRoute(routeId, this._logger, singleServerInvoker, label); + this._routes.set(routeId, route); + routeIds.add(routeId); + serverRouteMap.set(serverId, routeId); + + const address = URI.parse(`http://127.0.0.1:${this._port}/gateway/${routeId}`); + this._logger.info(`[McpGatewayService] Created route ${routeId} for server '${label}' (${serverId}) at ${address}`); + + return { label, address }; + } + + private _getGatewayServers(gatewayId: string): IMcpGatewayServerInfo[] { + const serverRouteMap = this._gatewayServerRoutes.get(gatewayId); + if (!serverRouteMap) { + return []; + } + const servers: IMcpGatewayServerInfo[] = []; + for (const [_serverId, routeId] of serverRouteMap) { + const route = this._routes.get(routeId); + if (route) { + servers.push({ + label: route.label, + address: URI.parse(`http://127.0.0.1:${this._port}/gateway/${routeId}`), + }); + } + } + return servers; + } + + private _disposeRoute(routeId: string): void { + const route = this._routes.get(routeId); + if (route) { + route.dispose(); + this._routes.delete(routeId); + this._logger.info(`[McpGatewayService] Disposed route: ${routeId}`); + } } async disposeGateway(gatewayId: string): Promise { - const gateway = this._gateways.get(gatewayId); - if (!gateway) { + if (!this._gatewayRoutes.has(gatewayId)) { this._logger.warn(`[McpGatewayService] Attempted to dispose unknown gateway: ${gatewayId}`); return; } - gateway.dispose(); - this._gateways.delete(gatewayId); - this._gatewayToClient.delete(gatewayId); - this._logger.info(`[McpGatewayService] Disposed gateway: ${gatewayId} (remaining: ${this._gateways.size})`); + this._cleanupGateway(gatewayId); + this._logger.info(`[McpGatewayService] Disposed gateway: ${gatewayId} (remaining routes: ${this._routes.size})`); - // If no more gateways, shut down the server - if (this._gateways.size === 0) { + // If no more routes, shut down the server + if (this._routes.size === 0) { this._stopServer(); } } @@ -102,16 +247,14 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService } if (gatewaysToDispose.length > 0) { - this._logger.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}: [${gatewaysToDispose.join(', ')}]`); + this._logger.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}`); for (const gatewayId of gatewaysToDispose) { - this._gateways.get(gatewayId)?.dispose(); - this._gateways.delete(gatewayId); - this._gatewayToClient.delete(gatewayId); + this._cleanupGateway(gatewayId); } - // If no more gateways, shut down the server - if (this._gateways.size === 0) { + // If no more routes, shut down the server + if (this._routes.size === 0) { this._stopServer(); } } @@ -187,7 +330,7 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService return; } - this._logger.info('[McpGatewayService] Stopping server (no more gateways)'); + this._logger.info('[McpGatewayService] Stopping server (no more routes)'); this._server.close(err => { if (err) { @@ -205,38 +348,45 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService const url = new URL(req.url!, `http://${req.headers.host}`); const pathParts = url.pathname.split('/').filter(Boolean); - this._logger.debug(`[McpGatewayService] ${req.method} ${url.pathname} (active gateways: ${this._gateways.size})`); + this._logger.debug(`[McpGatewayService] ${req.method} ${url.pathname} (active routes: ${this._routes.size})`); - // Expected path: /gateway/{gatewayId} + // Expected path: /gateway/{routeId} if (pathParts.length >= 2 && pathParts[0] === 'gateway') { - const gatewayId = pathParts[1]; - const gateway = this._gateways.get(gatewayId); + const routeId = pathParts[1]; + const route = this._routes.get(routeId); - if (gateway) { - gateway.handleRequest(req, res); + if (route) { + route.handleRequest(req, res); return; } } // Not found - this._logger.warn(`[McpGatewayService] ${req.method} ${url.pathname}: gateway not found`); + this._logger.warn(`[McpGatewayService] ${req.method} ${url.pathname}: route not found`); res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Gateway not found' })); } override dispose(): void { - this._logger.info(`[McpGatewayService] Disposing service (gateways: ${this._gateways.size})`); + this._logger.info(`[McpGatewayService] Disposing service (routes: ${this._routes.size})`); this._stopServer(); - for (const gateway of this._gateways.values()) { - gateway.dispose(); + for (const route of this._routes.values()) { + route.dispose(); } - this._gateways.clear(); + this._routes.clear(); + this._gatewayRoutes.clear(); + this._gatewayServerRoutes.clear(); + this._gatewayToClient.clear(); + for (const disposables of this._gatewayDisposables.values()) { + disposables.dispose(); + } + this._gatewayDisposables.clear(); super.dispose(); } } /** - * Represents a single MCP gateway route. + * Represents a single MCP gateway route for one MCP server. */ class McpGatewayRoute extends Disposable { private readonly _sessions = new Map(); @@ -244,15 +394,16 @@ class McpGatewayRoute extends Disposable { private static readonly SessionHeaderName = 'mcp-session-id'; constructor( - public readonly gatewayId: string, + public readonly routeId: string, private readonly _logger: ILogger, - private readonly _toolInvoker: IMcpGatewayToolInvoker, + private readonly _serverInvoker: IMcpGatewaySingleServerInvoker, + public label: string = '', ) { super(); } handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { - this._logger.debug(`[McpGateway][route ${this.gatewayId}] ${req.method} request (sessions: ${this._sessions.size})`); + this._logger.debug(`[McpGateway][route ${this.routeId}] ${req.method} request (sessions: ${this._sessions.size})`); if (req.method === 'POST') { void this._handlePost(req, res); @@ -273,7 +424,7 @@ class McpGatewayRoute extends Disposable { } public override dispose(): void { - this._logger.info(`[McpGateway][route ${this.gatewayId}] Disposing route (sessions: ${this._sessions.size})`); + this._logger.info(`[McpGateway][route ${this.routeId}] Disposing route (sessions: ${this._sessions.size})`); for (const session of this._sessions.values()) { session.dispose(); } @@ -294,7 +445,7 @@ class McpGatewayRoute extends Disposable { return; } - this._logger.info(`[McpGateway][route ${this.gatewayId}] Deleting session ${sessionId}`); + this._logger.info(`[McpGateway][route ${this.routeId}] Deleting session ${sessionId}`); session.dispose(); this._sessions.delete(sessionId); res.writeHead(204); @@ -314,7 +465,7 @@ class McpGatewayRoute extends Disposable { return; } - this._logger.info(`[McpGateway][route ${this.gatewayId}] SSE connection requested for session ${sessionId}`); + this._logger.info(`[McpGateway][route ${this.routeId}] SSE connection requested for session ${sessionId}`); session.attachSseClient(req, res); } @@ -325,13 +476,13 @@ class McpGatewayRoute extends Disposable { return; } - this._logger.debug(`[McpGateway][route ${this.gatewayId}] Handling POST`); + this._logger.debug(`[McpGateway][route ${this.routeId}] Handling POST`); let message: JsonRpcMessage | JsonRpcMessage[]; try { message = JSON.parse(body) as JsonRpcMessage | JsonRpcMessage[]; } catch (error) { - this._logger.warn(`[McpGateway][route ${this.gatewayId}] JSON parse error: ${error instanceof Error ? error.message : String(error)}`); + this._logger.warn(`[McpGateway][route ${this.routeId}] JSON parse error: ${error instanceof Error ? error.message : String(error)}`); res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(JsonRpcProtocol.createParseError('Parse error', error instanceof Error ? error.message : String(error)))); return; @@ -352,14 +503,14 @@ class McpGatewayRoute extends Disposable { }; if (responses.length === 0) { - this._logger.debug(`[McpGateway][route ${this.gatewayId}] POST response: 202 (no content)`); + this._logger.debug(`[McpGateway][route ${this.routeId}] POST response: 202 (no content)`); res.writeHead(202, headers); res.end(); return; } const responseBody = JSON.stringify(Array.isArray(message) ? responses : responses[0]); - this._logger.debug(`[McpGateway][route ${this.gatewayId}] POST response: 200, body: ${responseBody}`); + this._logger.debug(`[McpGateway][route ${this.routeId}] POST response: 200, body: ${responseBody}`); res.writeHead(200, headers); res.end(responseBody); } catch (error) { @@ -372,7 +523,7 @@ class McpGatewayRoute extends Disposable { if (headerSessionId) { const existing = this._sessions.get(headerSessionId); if (!existing) { - this._logger.warn(`[McpGateway][route ${this.gatewayId}] Session not found: ${headerSessionId}`); + this._logger.warn(`[McpGateway][route ${this.routeId}] Session not found: ${headerSessionId}`); this._respondHttpError(res, 404, 'Session not found'); return undefined; } @@ -386,16 +537,16 @@ class McpGatewayRoute extends Disposable { } const sessionId = generateUuid(); - this._logger.info(`[McpGateway][route ${this.gatewayId}] Creating new session ${sessionId}`); + this._logger.info(`[McpGateway][route ${this.routeId}] Creating new session ${sessionId}`); const session = new McpGatewaySession(sessionId, this._logger, () => { this._sessions.delete(sessionId); - }, this._toolInvoker); + }, this._serverInvoker); this._sessions.set(sessionId, session); return session; } private _respondHttpError(res: http.ServerResponse, statusCode: number, error: string): void { - this._logger.debug(`[McpGateway][route ${this.gatewayId}] HTTP error response: ${statusCode} ${error}`); + this._logger.debug(`[McpGateway][route ${this.routeId}] HTTP error response: ${statusCode} ${error}`); res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: statusCode, message: error } } satisfies JsonRpcMessage)); } diff --git a/src/vs/platform/mcp/node/mcpGatewaySession.ts b/src/vs/platform/mcp/node/mcpGatewaySession.ts index 20f6d23dc71..067899cb32f 100644 --- a/src/vs/platform/mcp/node/mcpGatewaySession.ts +++ b/src/vs/platform/mcp/node/mcpGatewaySession.ts @@ -11,7 +11,7 @@ import { import { Disposable } from '../../../base/common/lifecycle.js'; import { hasKey } from '../../../base/common/types.js'; import { ILogger } from '../../log/common/log.js'; -import { IMcpGatewayToolInvoker } from '../common/mcpGateway.js'; +import { IMcpGatewaySingleServerInvoker } from '../common/mcpGateway.js'; import { MCP } from '../common/modelContextProtocol.js'; const MCP_LATEST_PROTOCOL_VERSION = '2025-11-25'; @@ -26,56 +26,6 @@ const MCP_INVALID_REQUEST = -32600; const MCP_METHOD_NOT_FOUND = -32601; const MCP_INVALID_PARAMS = -32602; -const GATEWAY_URI_AUTHORITY_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/?#]*)(.*)/; - -/** - * Encodes a resource URI for the gateway by appending `-{serverIndex}` to the authority. - * This namespaces resources from different MCP servers served through the same gateway. - */ -export function encodeGatewayResourceUri(uri: string, serverIndex: number): string { - const match = uri.match(GATEWAY_URI_AUTHORITY_RE); - if (!match) { - return uri; - } - const [, prefix, authority, rest] = match; - return `${prefix}${authority}-${serverIndex}${rest}`; -} - -/** - * Decodes a gateway-encoded resource URI, extracting the server index and original URI. - */ -export function decodeGatewayResourceUri(uri: string): { serverIndex: number; originalUri: string } { - const match = uri.match(GATEWAY_URI_AUTHORITY_RE); - if (!match) { - throw new JsonRpcError(MCP_INVALID_PARAMS, `Invalid resource URI: ${uri}`); - } - const [, prefix, authority, rest] = match; - const suffixMatch = authority.match(/^(.*)-([0-9]+)$/); - if (!suffixMatch) { - throw new JsonRpcError(MCP_INVALID_PARAMS, `Invalid gateway resource URI (no server index): ${uri}`); - } - const [, originalAuthority, indexStr] = suffixMatch; - return { - serverIndex: parseInt(indexStr, 10), - originalUri: `${prefix}${originalAuthority}${rest}`, - }; -} - -function encodeResourceUrisInContent(content: MCP.ContentBlock[], serverIndex: number): MCP.ContentBlock[] { - return content.map(block => { - if (block.type === 'resource_link') { - return { ...block, uri: encodeGatewayResourceUri(block.uri, serverIndex) }; - } - if (block.type === 'resource') { - return { - ...block, - resource: { ...block.resource, uri: encodeGatewayResourceUri(block.resource.uri, serverIndex) }, - }; - } - return block; - }); -} - export class McpGatewaySession extends Disposable { private readonly _rpc: JsonRpcProtocol; private readonly _sseClients = new Set(); @@ -86,7 +36,7 @@ export class McpGatewaySession extends Disposable { public readonly id: string, private readonly _logService: ILogger, private readonly _onDidDispose: () => void, - private readonly _toolInvoker: IMcpGatewayToolInvoker, + private readonly _serverInvoker: IMcpGatewaySingleServerInvoker, ) { super(); @@ -98,7 +48,7 @@ export class McpGatewaySession extends Disposable { } )); - this._register(this._toolInvoker.onDidChangeTools(() => { + this._register(this._serverInvoker.onDidChangeTools(() => { if (!this._isInitialized) { return; } @@ -107,7 +57,7 @@ export class McpGatewaySession extends Disposable { this._rpc.sendNotification({ method: 'notifications/tools/list_changed' }); })); - this._register(this._toolInvoker.onDidChangeResources(() => { + this._register(this._serverInvoker.onDidChangeResources(() => { if (!this._isInitialized) { return; } @@ -282,39 +232,25 @@ export class McpGatewaySession extends Disposable { this._logService.debug(`[McpGateway][session ${this.id}] Calling tool '${params.name}' with args: ${JSON.stringify(argumentsValue)}`); try { - const { result, serverIndex } = await this._toolInvoker.callTool(params.name, argumentsValue); + const result = await this._serverInvoker.callTool(params.name, argumentsValue); this._logService.debug(`[McpGateway][session ${this.id}] Tool '${params.name}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); - return { - ...result, - content: encodeResourceUrisInContent(result.content, serverIndex), - }; + return result; } catch (error) { this._logService.error(`[McpGateway][session ${this.id}] Tool '${params.name}' invocation failed`, error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); } } - private _handleListTools(): unknown { - return this._toolInvoker.listTools() - .then(tools => { - this._logService.debug(`[McpGateway][session ${this.id}] Listed ${tools.length} tool(s): [${tools.map(t => t.name).join(', ')}]`); - return { tools }; - }); + private async _handleListTools(): Promise { + const tools = await this._serverInvoker.listTools(); + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${tools.length} tool(s): [${tools.map(t => t.name).join(', ')}]`); + return { tools: tools as MCP.Tool[] }; } private async _handleListResources(): Promise { - const serverResults = await this._toolInvoker.listResources(); - const allResources: MCP.Resource[] = []; - for (const { serverIndex, resources } of serverResults) { - for (const resource of resources) { - allResources.push({ - ...resource, - uri: encodeGatewayResourceUri(resource.uri, serverIndex), - }); - } - } - this._logService.debug(`[McpGateway][session ${this.id}] Listed ${allResources.length} resource(s) from ${serverResults.length} server(s)`); - return { resources: allResources }; + const resources = await this._serverInvoker.listResources(); + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${resources.length} resource(s)`); + return { resources: resources as MCP.Resource[] }; } private async _handleReadResource(request: IJsonRpcRequest): Promise { @@ -323,36 +259,21 @@ export class McpGatewaySession extends Disposable { throw new JsonRpcError(MCP_INVALID_PARAMS, 'Missing resource URI'); } - const { serverIndex, originalUri } = decodeGatewayResourceUri(params.uri); - this._logService.debug(`[McpGateway][session ${this.id}] Reading resource '${originalUri}' from server ${serverIndex}`); + this._logService.debug(`[McpGateway][session ${this.id}] Reading resource '${params.uri}'`); try { - const result = await this._toolInvoker.readResource(serverIndex, originalUri); + const result = await this._serverInvoker.readResource(params.uri); this._logService.debug(`[McpGateway][session ${this.id}] Resource read returned ${result.contents.length} content(s)`); - return { - contents: result.contents.map(content => ({ - ...content, - uri: encodeGatewayResourceUri(content.uri, serverIndex), - })), - }; + return result; } catch (error) { - this._logService.error(`[McpGateway][session ${this.id}] Resource read failed for '${originalUri}'`, error); + this._logService.error(`[McpGateway][session ${this.id}] Resource read failed for '${params.uri}'`, error); throw new JsonRpcError(MCP_INVALID_PARAMS, String(error)); } } private async _handleListResourceTemplates(): Promise { - const serverResults = await this._toolInvoker.listResourceTemplates(); - const allTemplates: MCP.ResourceTemplate[] = []; - for (const { serverIndex, resourceTemplates } of serverResults) { - for (const template of resourceTemplates) { - allTemplates.push({ - ...template, - uriTemplate: encodeGatewayResourceUri(template.uriTemplate, serverIndex), - }); - } - } - this._logService.debug(`[McpGateway][session ${this.id}] Listed ${allTemplates.length} resource template(s) from ${serverResults.length} server(s)`); - return { resourceTemplates: allTemplates }; + const resourceTemplates = await this._serverInvoker.listResourceTemplates(); + this._logService.debug(`[McpGateway][session ${this.id}] Listed ${resourceTemplates.length} resource template(s)`); + return { resourceTemplates: resourceTemplates as MCP.ResourceTemplate[] }; } } diff --git a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts index d30d166d09f..029e51118b7 100644 --- a/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts +++ b/src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts @@ -11,7 +11,7 @@ import { IJsonRpcErrorResponse, IJsonRpcSuccessResponse } from '../../../../base import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { MCP } from '../../common/modelContextProtocol.js'; -import { decodeGatewayResourceUri, encodeGatewayResourceUri, McpGatewaySession } from '../../node/mcpGatewaySession.js'; +import { McpGatewaySession } from '../../node/mcpGatewaySession.js'; class TestServerResponse extends EventEmitter { public statusCode: number | undefined; @@ -73,16 +73,13 @@ suite('McpGatewaySession', () => { onDidChangeResources: onDidChangeResources.event, listTools: async () => tools, callTool: async (_name: string, args: Record) => ({ - result: { - content: [{ type: 'text' as const, text: `Hello, ${typeof args.name === 'string' ? args.name : 'World'}!` }] - }, - serverIndex: 0, + content: [{ type: 'text' as const, text: `Hello, ${typeof args.name === 'string' ? args.name : 'World'}!` }] }), - listResources: async () => [{ serverIndex: 0, resources }], - readResource: async (_serverIndex: number, _uri: string) => ({ + listResources: async () => resources, + readResource: async (_uri: string) => ({ contents: [{ uri: 'file:///test/resource.txt', text: 'hello world', mimeType: 'text/plain' }], }), - listResourceTemplates: async () => [{ serverIndex: 0, resourceTemplates: [{ uriTemplate: 'file:///test/{name}', name: 'Test Template' }] }], + listResourceTemplates: async () => [{ uriTemplate: 'file:///test/{name}', name: 'Test Template' }], } }; } @@ -380,7 +377,7 @@ suite('McpGatewaySession', () => { onDidChangeResources.dispose(); }); - test('serves resources/list with encoded URIs', async () => { + test('serves resources/list with raw URIs', async () => { const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-8', new NullLogService(), () => { }, invoker); @@ -391,14 +388,14 @@ suite('McpGatewaySession', () => { const response = responses[0] as IJsonRpcSuccessResponse; const resources = (response.result as { resources: Array<{ uri: string; name: string }> }).resources; assert.strictEqual(resources.length, 1); - assert.strictEqual(resources[0].uri, 'file://-0/test/resource.txt'); + assert.strictEqual(resources[0].uri, 'file:///test/resource.txt'); assert.strictEqual(resources[0].name, 'resource.txt'); session.dispose(); onDidChangeTools.dispose(); onDidChangeResources.dispose(); }); - test('serves resources/read with URI decoding and re-encoding', async () => { + test('serves resources/read with raw URIs', async () => { const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-9', new NullLogService(), () => { }, invoker); @@ -409,19 +406,19 @@ suite('McpGatewaySession', () => { jsonrpc: '2.0', id: 2, method: 'resources/read', - params: { uri: 'file://-0/test/resource.txt' }, + params: { uri: 'file:///test/resource.txt' }, }); const response = responses[0] as IJsonRpcSuccessResponse; const contents = (response.result as { contents: Array<{ uri: string; text: string }> }).contents; assert.strictEqual(contents.length, 1); - assert.strictEqual(contents[0].uri, 'file://-0/test/resource.txt'); + assert.strictEqual(contents[0].uri, 'file:///test/resource.txt'); assert.strictEqual(contents[0].text, 'hello world'); session.dispose(); onDidChangeTools.dispose(); onDidChangeResources.dispose(); }); - test('serves resources/templates/list with encoded URI templates', async () => { + test('serves resources/templates/list with raw URI templates', async () => { const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker(); const session = new McpGatewaySession('session-10', new NullLogService(), () => { }, invoker); @@ -432,71 +429,10 @@ suite('McpGatewaySession', () => { const response = responses[0] as IJsonRpcSuccessResponse; const templates = (response.result as { resourceTemplates: Array<{ uriTemplate: string; name: string }> }).resourceTemplates; assert.strictEqual(templates.length, 1); - assert.strictEqual(templates[0].uriTemplate, 'file://-0/test/{name}'); + assert.strictEqual(templates[0].uriTemplate, 'file:///test/{name}'); assert.strictEqual(templates[0].name, 'Test Template'); session.dispose(); onDidChangeTools.dispose(); onDidChangeResources.dispose(); }); }); - -suite('Gateway Resource URI encoding', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - test('encodes and decodes URI with authority', () => { - const encoded = encodeGatewayResourceUri('https://example.com/resource', 3); - assert.strictEqual(encoded, 'https://example.com-3/resource'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 3); - assert.strictEqual(decoded.originalUri, 'https://example.com/resource'); - }); - - test('encodes and decodes URI with empty authority', () => { - const encoded = encodeGatewayResourceUri('file:///path/to/file', 0); - assert.strictEqual(encoded, 'file://-0/path/to/file'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 0); - assert.strictEqual(decoded.originalUri, 'file:///path/to/file'); - }); - - test('encodes and decodes URI with authority containing hyphens', () => { - const encoded = encodeGatewayResourceUri('https://my-server.example.com/res', 12); - assert.strictEqual(encoded, 'https://my-server.example.com-12/res'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 12); - assert.strictEqual(decoded.originalUri, 'https://my-server.example.com/res'); - }); - - test('encodes and decodes URI with port', () => { - const encoded = encodeGatewayResourceUri('http://localhost:8080/api', 5); - assert.strictEqual(encoded, 'http://localhost:8080-5/api'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 5); - assert.strictEqual(decoded.originalUri, 'http://localhost:8080/api'); - }); - - test('encodes and decodes URI with query and fragment', () => { - const encoded = encodeGatewayResourceUri('https://example.com/resource?q=1#section', 2); - assert.strictEqual(encoded, 'https://example.com-2/resource?q=1#section'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 2); - assert.strictEqual(decoded.originalUri, 'https://example.com/resource?q=1#section'); - }); - - test('encodes and decodes custom scheme URIs', () => { - const encoded = encodeGatewayResourceUri('custom://myhost/path', 7); - assert.strictEqual(encoded, 'custom://myhost-7/path'); - const decoded = decodeGatewayResourceUri(encoded); - assert.strictEqual(decoded.serverIndex, 7); - assert.strictEqual(decoded.originalUri, 'custom://myhost/path'); - }); - - test('returns URI unchanged if no scheme match', () => { - const encoded = encodeGatewayResourceUri('not-a-uri', 1); - assert.strictEqual(encoded, 'not-a-uri'); - }); - - test('throws on decode of URI without server index suffix', () => { - assert.throws(() => decodeGatewayResourceUri('https://example.com/resource')); - }); -}); diff --git a/src/vs/workbench/api/browser/mainThreadMcp.ts b/src/vs/workbench/api/browser/mainThreadMcp.ts index f71ccdba8fe..f36df0c7f7b 100644 --- a/src/vs/workbench/api/browser/mainThreadMcp.ts +++ b/src/vs/workbench/api/browser/mainThreadMcp.ts @@ -18,7 +18,7 @@ import { IDialogService, IPromptButton } from '../../../platform/dialogs/common/ import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { LogLevel } from '../../../platform/log/common/log.js'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; -import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../../contrib/mcp/common/mcpGatewayService.js'; +import { IWorkbenchMcpGatewayService } from '../../contrib/mcp/common/mcpGatewayService.js'; import { IMcpMessageTransport, IMcpRegistry } from '../../contrib/mcp/common/mcpRegistryTypes.js'; import { extensionPrefixedIdentifier, McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust, UserInteractionRequiredError } from '../../contrib/mcp/common/mcpTypes.js'; import { MCP } from '../../contrib/mcp/common/modelContextProtocol.js'; @@ -46,7 +46,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { servers: ISettableObservable; dispose(): void; }>()); - private readonly _gateways = this._register(new DisposableMap()); + private readonly _gateways = this._register(new DisposableMap()); constructor( private readonly _extHostContext: IExtHostContext, @@ -401,7 +401,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { this._telemetryService.publicLog2('mcp/authSetup', data); } - async $startMcpGateway(): Promise<{ address: URI; gatewayId: string } | undefined> { + async $startMcpGateway(): Promise<{ servers: { label: string; address: URI }[]; gatewayId: string } | undefined> { const result = await this._mcpGatewayService.createGateway(this._extHostContext.extensionHostKind === ExtensionHostKind.Remote); if (!result) { return undefined; @@ -413,10 +413,15 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape { } const gatewayId = generateUuid(); - this._gateways.set(gatewayId, result); + const store = new DisposableStore(); + store.add(result); + store.add(result.onDidChangeServers(servers => { + this._proxy.$onDidChangeGatewayServers(gatewayId, servers.map(s => ({ label: s.label, address: s.address }))); + })); + this._gateways.set(gatewayId, store); return { - address: result.address, + servers: result.servers.map(s => ({ label: s.label, address: s.address })), gatewayId, }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 66eebd6da50..564e9ba689b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3425,6 +3425,7 @@ export interface ExtHostMcpShape { $sendMessage(id: number, message: string): void; $waitForInitialCollectionProviders(): Promise; $onDidChangeMcpServerDefinitions(servers: McpServerDefinition.Serialized[]): void; + $onDidChangeGatewayServers(gatewayId: string, servers: { label: string; address: UriComponents }[]): void; } export interface IMcpAuthenticationDetails { @@ -3465,7 +3466,7 @@ export interface MainThreadMcpShape { $getTokenFromServerMetadata(id: number, authDetails: IMcpAuthenticationDetails, options?: IMcpAuthenticationOptions): Promise; $getTokenForProviderId(id: number, providerId: string, scopes: string[], options?: IMcpAuthenticationOptions): Promise; $logMcpAuthSetup(data: IAuthMetadataSource): void; - $startMcpGateway(): Promise<{ address: UriComponents; gatewayId: string } | undefined>; + $startMcpGateway(): Promise<{ servers: { label: string; address: UriComponents }[]; gatewayId: string } | undefined>; $disposeMcpGateway(gatewayId: string): void; } diff --git a/src/vs/workbench/api/common/extHostMcp.ts b/src/vs/workbench/api/common/extHostMcp.ts index 27e6c334d6b..1554db1996b 100644 --- a/src/vs/workbench/api/common/extHostMcp.ts +++ b/src/vs/workbench/api/common/extHostMcp.ts @@ -41,7 +41,7 @@ export interface IExtHostMpcService extends ExtHostMcpShape { /** Returns all MCP server definitions known to the editor. */ readonly mcpServerDefinitions: readonly vscode.McpServerDefinition[]; - /** Starts an MCP gateway that exposes MCP servers via an HTTP endpoint. */ + /** Starts an MCP gateway that exposes MCP servers via HTTP endpoints. */ startMcpGateway(): Promise; } @@ -80,6 +80,12 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService readonly onDidChangeMcpServerDefinitions: Event = this._onDidChangeMcpServerDefinitions.event; private _mcpServerDefinitions: readonly vscode.McpServerDefinition[] = []; + // Active gateways with their server emitters for dynamic updates + private readonly _activeGateways = new Map; + }>(); + constructor( @IExtHostRpcService extHostRpc: IExtHostRpcService, @ILogService protected readonly _logService: ILogService, @@ -264,16 +270,41 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService return undefined; } - const address = URI.revive(result.address); const gatewayId = result.gatewayId; + const servers: vscode.McpGatewayServer[] = result.servers.map(s => ({ + label: s.label, + address: URI.revive(s.address), + })); + const onDidChangeServers = new Emitter(); + + this._activeGateways.set(gatewayId, { servers, onDidChangeServers }); return { - address, + get servers() { return servers; }, + onDidChangeServers: onDidChangeServers.event, dispose: () => { + this._activeGateways.delete(gatewayId); + onDidChangeServers.dispose(); this._proxy.$disposeMcpGateway(gatewayId); } }; } + + /** Called by main thread to notify that a gateway's server set has changed. */ + $onDidChangeGatewayServers(gatewayId: string, newServers: { label: string; address: UriComponents }[]): void { + const gateway = this._activeGateways.get(gatewayId); + if (!gateway) { + return; + } + + const servers: vscode.McpGatewayServer[] = newServers.map(s => ({ + label: s.label, + address: URI.revive(s.address), + })); + gateway.servers.length = 0; + gateway.servers.push(...servers); + gateway.onDidChangeServers.fire(servers); + } } const enum HttpMode { diff --git a/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts index 4d2d9df2c49..467d4f995e4 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpGatewayService.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; +import { IMcpGatewayServerInfo, IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; -import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; +import { IMcpGatewayResult, IMcpGatewayResultServer, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; /** * Browser implementation of the MCP Gateway Service. @@ -47,11 +48,20 @@ export class BrowserMcpGatewayService implements IWorkbenchMcpGatewayService { return connection.withChannel(McpGatewayChannelName, async channel => { const service = ProxyChannel.toService(channel); const info = await service.createGateway(undefined); - const address = URI.revive(info.address); - this._logService.info(`[McpGateway][BrowserWorkbench] Remote gateway created: ${address}`); + const servers = reviveServers(info.servers); + this._logService.info(`[McpGateway][BrowserWorkbench] Remote gateway created with ${servers.length} server(s)`); + + const onDidChangeServers = Event.map( + Event.filter( + channel.listen<{ gatewayId: string; servers: readonly IMcpGatewayServerInfo[] }>('onDidChangeGatewayServers'), + e => e.gatewayId === info.gatewayId, + ), + e => reviveServers(e.servers), + ); return { - address, + servers, + onDidChangeServers, dispose: () => { this._logService.info(`[McpGateway][BrowserWorkbench] Disposing remote gateway: ${info.gatewayId}`); service.disposeGateway(info.gatewayId); @@ -60,3 +70,7 @@ export class BrowserMcpGatewayService implements IWorkbenchMcpGatewayService { }); } } + +function reviveServers(servers: readonly IMcpGatewayServerInfo[]): IMcpGatewayResultServer[] { + return servers.map(s => ({ label: s.label, address: URI.revive(s.address) })); +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts index 7376729aa00..f97a7a8f92c 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpGatewayService.ts @@ -3,20 +3,34 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../base/common/event.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; export const IWorkbenchMcpGatewayService = createDecorator('IWorkbenchMcpGatewayService'); +/** + * A single server entry exposed by the gateway at the workbench layer. + */ +export interface IMcpGatewayResultServer { + readonly label: string; + readonly address: URI; +} + /** * Result of creating an MCP gateway, which is itself disposable. */ export interface IMcpGatewayResult extends IDisposable { /** - * The address of the HTTP endpoint for this gateway. + * The servers currently exposed by this gateway. */ - readonly address: URI; + readonly servers: readonly IMcpGatewayResultServer[]; + + /** + * Event that fires when the set of servers changes. + */ + readonly onDidChangeServers: Event; } /** diff --git a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts index 7e4e2c18691..172f0650aab 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpGatewayToolBrokerChannel.ts @@ -9,27 +9,31 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IGatewayCallToolResult, IGatewayServerResources, IGatewayServerResourceTemplates } from '../../../../platform/mcp/common/mcpGateway.js'; +import { IMcpGatewayServerDescriptor } from '../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../../../platform/mcp/common/modelContextProtocol.js'; import { McpServer } from './mcpServer.js'; import { IMcpServer, IMcpService, McpCapability, McpServerCacheState, McpToolVisibility } from './mcpTypes.js'; import { startServerAndWaitForLiveTools } from './mcpTypesUtils.js'; -interface ICallToolArgs { +interface ICallToolForServerArgs { + serverId: string; name: string; args: Record; } -interface IReadResourceArgs { - serverIndex: number; +interface IReadResourceForServerArgs { + serverId: string; uri: string; } +interface IServerIdArg { + serverId: string; +} + export class McpGatewayToolBrokerChannel extends Disposable implements IServerChannel { private readonly _onDidChangeTools = this._register(new Emitter()); private readonly _onDidChangeResources = this._register(new Emitter()); - private readonly _serverIdMap = new Map(); - private _nextServerIndex = 0; + private readonly _onDidChangeServers = this._register(new Emitter()); /** * Per-server promise that races server startup against the grace period timeout. @@ -78,21 +82,23 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh resourcesInitialized = true; } })); + + let serversInitialized = false; + this._register(autorun(reader => { + const servers = this._mcpService.servers.read(reader); + + if (serversInitialized) { + this._logService.debug('[McpGateway][ToolBroker] Servers changed, firing onDidChangeServers'); + this._onDidChangeServers.fire(servers.map(s => ({ id: s.definition.id, label: s.definition.label }))); + } else { + serversInitialized = true; + } + })); } - private _getServerIndex(server: IMcpServer): number { - const defId = server.definition.id; - let index = this._serverIdMap.get(defId); - if (index === undefined) { - index = this._nextServerIndex++; - this._serverIdMap.set(defId, index); - } - return index; - } - - private _getServerByIndex(serverIndex: number): IMcpServer | undefined { + private _getServerById(serverId: string): IMcpServer | undefined { for (const server of this._mcpService.servers.get()) { - if (this._getServerIndex(server) === serverIndex) { + if (server.definition.id === serverId) { return server; } } @@ -128,10 +134,6 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh private async _shouldUseCachedData(server: IMcpServer): Promise { const cacheState = server.cacheState.get(); if (cacheState === McpServerCacheState.Unknown || cacheState === McpServerCacheState.Outdated) { - // On first list call: wait up to the grace period for the server to start. - // On subsequent calls: the stored promise is already resolved, returns immediately. - // Outdated servers get the same grace period as Unknown — a prior fast startup - // does not guarantee a fast restart. await this._waitForStartup(server); const newState = server.cacheState.get(); return newState === McpServerCacheState.Live @@ -149,6 +151,8 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh return this._onDidChangeTools.event as Event; case 'onDidChangeResources': return this._onDidChangeResources.event as Event; + case 'onDidChangeServers': + return this._onDidChangeServers.event as Event; } throw new Error(`Invalid listen: ${event}`); @@ -158,26 +162,33 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh this._logService.debug(`[McpGateway][ToolBroker] IPC call: ${command}`); switch (command) { - case 'listTools': { - const tools = await this._listTools(); + case 'listServers': { + const servers = this._listServers(); + return servers as T; + } + case 'listToolsForServer': { + const { serverId } = arg as IServerIdArg; + const tools = await this._listToolsForServer(serverId); return tools as T; } - case 'callTool': { - const { name, args } = arg as ICallToolArgs; - const result = await this._callTool(name, args || {}, cancellationToken); + case 'callToolForServer': { + const { serverId, name, args } = arg as ICallToolForServerArgs; + const result = await this._callToolForServer(serverId, name, args || {}, cancellationToken); return result as T; } - case 'listResources': { - const resources = await this._listResources(); + case 'listResourcesForServer': { + const { serverId } = arg as IServerIdArg; + const resources = await this._listResourcesForServer(serverId); return resources as T; } - case 'readResource': { - const { serverIndex, uri } = arg as IReadResourceArgs; - const result = await this._readResource(serverIndex, uri, cancellationToken); + case 'readResourceForServer': { + const { serverId, uri } = arg as IReadResourceForServerArgs; + const result = await this._readResourceForServer(serverId, uri, cancellationToken); return result as T; } - case 'listResourceTemplates': { - const templates = await this._listResourceTemplates(); + case 'listResourceTemplatesForServer': { + const { serverId } = arg as IServerIdArg; + const templates = await this._listResourceTemplatesForServer(serverId); return templates as T; } } @@ -185,112 +196,114 @@ export class McpGatewayToolBrokerChannel extends Disposable implements IServerCh throw new Error(`Invalid call: ${command}`); } - private async _listTools(): Promise { + private _listServers(): readonly IMcpGatewayServerDescriptor[] { const servers = this._mcpService.servers.get(); - const perServer = await Promise.all(servers.map(async server => { - if (!await this._shouldUseCachedData(server)) { - this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' not ready, skipping tool listing`); - return [] as MCP.Tool[]; - } - return server.tools.get() - .filter(t => t.visibility & McpToolVisibility.Model) - .map(t => t.definition); - })); - - const mcpTools = perServer.flat(); - this._logService.debug(`[McpGateway][ToolBroker] listTools result: ${mcpTools.length} tool(s): [${mcpTools.map(t => t.name).join(', ')}]`); - - return mcpTools; - } - - private async _callTool(name: string, args: Record, token: CancellationToken = CancellationToken.None): Promise { - this._logService.debug(`[McpGateway][ToolBroker] callTool '${name}' with args: ${JSON.stringify(args)}`); - - for (const server of this._mcpService.servers.get()) { - const tool = server.tools.get().find(t => - t.definition.name === name && (t.visibility & McpToolVisibility.Model) - ); - - if (tool) { - this._logService.debug(`[McpGateway][ToolBroker] Found tool '${name}' on server '${server.definition.id}' (index=${this._getServerIndex(server)})`); - const result = await tool.call(args, undefined, token); - this._logService.debug(`[McpGateway][ToolBroker] Tool '${name}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); - return { result, serverIndex: this._getServerIndex(server) }; - } + const result: IMcpGatewayServerDescriptor[] = []; + for (const server of servers) { + result.push({ id: server.definition.id, label: server.definition.label }); } - - this._logService.warn(`[McpGateway][ToolBroker] Tool '${name}' not found on any server`); - throw new Error(`Unknown tool: ${name}`); - } - - private async _listResources(): Promise { - const results: IGatewayServerResources[] = []; - const servers = this._mcpService.servers.get(); - this._logService.debug(`[McpGateway][ToolBroker] listResources: ${servers.length} server(s) known`); - - await Promise.all(servers.map(async server => { - if (!await this._shouldUseCachedData(server)) { - return; - } - - const capabilities = server.capabilities.get(); - if (!capabilities || !(capabilities & McpCapability.Resources)) { - this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' has no resource capability, skipping`); - return; - } - - try { - const resources = await McpServer.callOn(server, h => h.listResources()); - this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' (index=${this._getServerIndex(server)}) listed ${resources.length} resource(s)`); - results.push({ serverIndex: this._getServerIndex(server), resources }); - } catch (error) { - this._logService.warn(`[McpGateway][ToolBroker] Server '${server.definition.id}' failed to list resources`, error); - } - })); - - this._logService.debug(`[McpGateway][ToolBroker] listResources result: ${results.length} server(s) with resources`); - return results; - } - - private async _readResource(serverIndex: number, uri: string, token: CancellationToken = CancellationToken.None): Promise { - const server = this._getServerByIndex(serverIndex); - if (!server) { - this._logService.warn(`[McpGateway][ToolBroker] readResource: unknown server index ${serverIndex}`); - throw new Error(`Unknown server index: ${serverIndex}`); - } - - this._logService.debug(`[McpGateway][ToolBroker] readResource '${uri}' from server '${server.definition.id}' (index=${serverIndex})`); - const result = await McpServer.callOn(server, h => h.readResource({ uri }, token), token); - this._logService.debug(`[McpGateway][ToolBroker] readResource returned ${result.contents.length} content(s)`); + this._logService.debug(`[McpGateway][ToolBroker] listServers result: ${result.length} server(s): [${result.map(s => s.label).join(', ')}]`); return result; } - private async _listResourceTemplates(): Promise { - const results: IGatewayServerResourceTemplates[] = []; - const servers = this._mcpService.servers.get(); - this._logService.debug(`[McpGateway][ToolBroker] listResourceTemplates: ${servers.length} server(s) known`); + private async _listToolsForServer(serverId: string): Promise { + const server = this._getServerById(serverId); + if (!server) { + this._logService.warn(`[McpGateway][ToolBroker] listToolsForServer: unknown server '${serverId}'`); + return []; + } + if (!await this._shouldUseCachedData(server)) { + this._logService.debug(`[McpGateway][ToolBroker] Server '${serverId}' not ready, skipping tool listing`); + return []; + } + const tools = server.tools.get() + .filter(t => t.visibility & McpToolVisibility.Model) + .map(t => t.definition); + this._logService.debug(`[McpGateway][ToolBroker] listToolsForServer '${serverId}': ${tools.length} tool(s)`); + return tools; + } - await Promise.all(servers.map(async server => { - if (!await this._shouldUseCachedData(server)) { - return; - } + private async _callToolForServer(serverId: string, name: string, args: Record, token: CancellationToken = CancellationToken.None): Promise { + this._logService.debug(`[McpGateway][ToolBroker] callToolForServer '${serverId}' tool '${name}' with args: ${JSON.stringify(args)}`); - const capabilities = server.capabilities.get(); - if (!capabilities || !(capabilities & McpCapability.Resources)) { - return; - } + const server = this._getServerById(serverId); + if (!server) { + throw new Error(`Unknown server: ${serverId}`); + } - try { - const resourceTemplates = await McpServer.callOn(server, h => h.listResourceTemplates()); - this._logService.debug(`[McpGateway][ToolBroker] Server '${server.definition.id}' (index=${this._getServerIndex(server)}) listed ${resourceTemplates.length} resource template(s)`); - results.push({ serverIndex: this._getServerIndex(server), resourceTemplates }); - } catch (error) { - this._logService.warn(`[McpGateway][ToolBroker] Server '${server.definition.id}' failed to list resource templates`, error); - } - })); + const tool = server.tools.get().find(t => + t.definition.name === name && (t.visibility & McpToolVisibility.Model) + ); + if (!tool) { + throw new Error(`Unknown tool '${name}' on server '${serverId}'`); + } - this._logService.debug(`[McpGateway][ToolBroker] listResourceTemplates result: ${results.length} server(s) with templates`); - return results; + const result = await tool.call(args, undefined, token); + this._logService.debug(`[McpGateway][ToolBroker] Tool '${name}' on '${serverId}' completed (isError=${result.isError ?? false}, content blocks=${result.content.length})`); + return result; + } + + private async _listResourcesForServer(serverId: string): Promise { + const server = this._getServerById(serverId); + if (!server) { + this._logService.warn(`[McpGateway][ToolBroker] listResourcesForServer: unknown server '${serverId}'`); + return []; + } + if (!await this._shouldUseCachedData(server)) { + return []; + } + + const capabilities = server.capabilities.get(); + if (!capabilities || !(capabilities & McpCapability.Resources)) { + this._logService.debug(`[McpGateway][ToolBroker] Server '${serverId}' has no resource capability`); + return []; + } + + try { + const resources = await McpServer.callOn(server, h => h.listResources()); + this._logService.debug(`[McpGateway][ToolBroker] Server '${serverId}' listed ${resources.length} resource(s)`); + return resources; + } catch (error) { + this._logService.warn(`[McpGateway][ToolBroker] Server '${serverId}' failed to list resources`, error); + return []; + } + } + + private async _readResourceForServer(serverId: string, uri: string, token: CancellationToken = CancellationToken.None): Promise { + const server = this._getServerById(serverId); + if (!server) { + throw new Error(`Unknown server: ${serverId}`); + } + + this._logService.debug(`[McpGateway][ToolBroker] readResourceForServer '${uri}' from server '${serverId}'`); + const result = await McpServer.callOn(server, h => h.readResource({ uri }, token), token); + this._logService.debug(`[McpGateway][ToolBroker] readResourceForServer returned ${result.contents.length} content(s)`); + return result; + } + + private async _listResourceTemplatesForServer(serverId: string): Promise { + const server = this._getServerById(serverId); + if (!server) { + this._logService.warn(`[McpGateway][ToolBroker] listResourceTemplatesForServer: unknown server '${serverId}'`); + return []; + } + if (!await this._shouldUseCachedData(server)) { + return []; + } + + const capabilities = server.capabilities.get(); + if (!capabilities || !(capabilities & McpCapability.Resources)) { + return []; + } + + try { + const resourceTemplates = await McpServer.callOn(server, h => h.listResourceTemplates()); + this._logService.debug(`[McpGateway][ToolBroker] Server '${serverId}' listed ${resourceTemplates.length} resource template(s)`); + return resourceTemplates; + } catch (error) { + this._logService.warn(`[McpGateway][ToolBroker] Server '${serverId}' failed to list resource templates`, error); + return []; + } } private async _ensureServerReady(server: IMcpServer): Promise { diff --git a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts index 71d0dcdc64b..5bf8d9cde62 100644 --- a/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts +++ b/src/vs/workbench/contrib/mcp/electron-browser/mcpGatewayService.ts @@ -3,13 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; -import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { IChannel, ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IMainProcessService } from '../../../../platform/ipc/common/mainProcessService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; +import { IMcpGatewayServerInfo, IMcpGatewayService, McpGatewayChannelName } from '../../../../platform/mcp/common/mcpGateway.js'; import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js'; -import { IMcpGatewayResult, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; +import { IMcpGatewayResult, IMcpGatewayResultServer, IWorkbenchMcpGatewayService } from '../common/mcpGatewayService.js'; /** * Electron workbench implementation of the MCP Gateway Service. @@ -21,15 +22,15 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { declare readonly _serviceBrand: undefined; private readonly _localPlatformService: IMcpGatewayService; + private readonly _localChannel: IChannel; constructor( @IMainProcessService mainProcessService: IMainProcessService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, @ILogService private readonly _logService: ILogService, ) { - this._localPlatformService = ProxyChannel.toService( - mainProcessService.getChannel(McpGatewayChannelName) - ); + this._localChannel = mainProcessService.getChannel(McpGatewayChannelName); + this._localPlatformService = ProxyChannel.toService(this._localChannel); } async createGateway(inRemote: boolean): Promise { @@ -44,11 +45,20 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { private async _createLocalGateway(): Promise { this._logService.info('[McpGateway][Workbench] Creating local gateway via main process'); const info = await this._localPlatformService.createGateway(undefined); - const address = URI.revive(info.address); - this._logService.info(`[McpGateway][Workbench] Local gateway created: ${address}`); + const servers = reviveServers(info.servers); + this._logService.info(`[McpGateway][Workbench] Local gateway created with ${servers.length} server(s)`); + + const onDidChangeServers = Event.map( + Event.filter( + this._localChannel.listen<{ gatewayId: string; servers: readonly IMcpGatewayServerInfo[] }>('onDidChangeGatewayServers'), + e => e.gatewayId === info.gatewayId, + ), + e => reviveServers(e.servers), + ); return { - address, + servers, + onDidChangeServers, dispose: () => { this._logService.info(`[McpGateway][Workbench] Disposing local gateway: ${info.gatewayId}`); this._localPlatformService.disposeGateway(info.gatewayId); @@ -67,11 +77,20 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { return connection.withChannel(McpGatewayChannelName, async channel => { const service = ProxyChannel.toService(channel); const info = await service.createGateway(undefined); - const address = URI.revive(info.address); - this._logService.info(`[McpGateway][Workbench] Remote gateway created: ${address}`); + const servers = reviveServers(info.servers); + this._logService.info(`[McpGateway][Workbench] Remote gateway created with ${servers.length} server(s)`); + + const onDidChangeServers = Event.map( + Event.filter( + channel.listen<{ gatewayId: string; servers: readonly IMcpGatewayServerInfo[] }>('onDidChangeGatewayServers'), + e => e.gatewayId === info.gatewayId, + ), + e => reviveServers(e.servers), + ); return { - address, + servers, + onDidChangeServers, dispose: () => { this._logService.info(`[McpGateway][Workbench] Disposing remote gateway: ${info.gatewayId}`); service.disposeGateway(info.gatewayId); @@ -80,3 +99,7 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService { }); } } + +function reviveServers(servers: readonly IMcpGatewayServerInfo[]): IMcpGatewayResultServer[] { + return servers.map(s => ({ label: s.label, address: URI.revive(s.address) })); +} diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts index e0dcab8d7f1..be81ff88ad3 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpGatewayToolBrokerChannel.test.ts @@ -10,7 +10,7 @@ import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelSc import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ContributionEnablementState } from '../../../chat/common/enablement.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { IGatewayCallToolResult } from '../../../../../platform/mcp/common/mcpGateway.js'; +import { IMcpGatewayServerDescriptor } from '../../../../../platform/mcp/common/mcpGateway.js'; import { MCP } from '../../common/modelContextProtocol.js'; import { McpGatewayToolBrokerChannel } from '../../common/mcpGatewayToolBrokerChannel.js'; import { IMcpIcons, IMcpServer, IMcpTool, McpConnectionState, McpServerCacheState, McpToolVisibility } from '../../common/mcpTypes.js'; @@ -19,7 +19,7 @@ import { TestMcpService } from './testMcpService.js'; suite('McpGatewayToolBrokerChannel', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('lists model-visible tools with namespaced identities', async () => { + test('lists model-visible tools for a specific server', async () => { const mcpService = new TestMcpService(); const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); @@ -33,18 +33,16 @@ suite('McpGatewayToolBrokerChannel', () => { mcpService.servers.set([serverA, serverB], undefined); - const result = await channel.call(undefined, 'listTools'); - const names = result.map(tool => tool.name).sort(); + const resultA = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); + assert.deepStrictEqual(resultA.map(t => t.name), ['mcp_serverA_echo']); - assert.deepStrictEqual(names, [ - 'mcp_serverA_echo', - 'mcp_serverB_echo', - ]); + const resultB = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverB' }); + assert.deepStrictEqual(resultB.map(t => t.name), ['mcp_serverB_echo']); channel.dispose(); }); - test('routes tool calls by namespaced identity', async () => { + test('routes tool calls to specific server', async () => { const mcpService = new TestMcpService(); const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); @@ -64,18 +62,20 @@ suite('McpGatewayToolBrokerChannel', () => { mcpService.servers.set([serverA, serverB], undefined); - const resultA = await channel.call(undefined, 'callTool', { + const resultA = await channel.call(undefined, 'callToolForServer', { + serverId: 'serverA', name: 'mcp_serverA_echo', args: { name: 'one' }, }); - const resultB = await channel.call(undefined, 'callTool', { + const resultB = await channel.call(undefined, 'callToolForServer', { + serverId: 'serverB', name: 'mcp_serverB_echo', args: { name: 'two' }, }); assert.deepStrictEqual(invoked, ['A:one', 'B:two']); - assert.strictEqual((resultA.result.content[0] as MCP.TextContent).text, 'from A'); - assert.strictEqual((resultB.result.content[0] as MCP.TextContent).text, 'from B'); + assert.strictEqual((resultA.content[0] as MCP.TextContent).text, 'from A'); + assert.strictEqual((resultB.content[0] as MCP.TextContent).text, 'from B'); channel.dispose(); }); @@ -117,7 +117,7 @@ suite('McpGatewayToolBrokerChannel', () => { ); mcpService.servers.set([server], undefined); - await channel.call(undefined, 'listTools'); + await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); assert.strictEqual(server.startCalls, 0); channel.dispose(); @@ -135,7 +135,7 @@ suite('McpGatewayToolBrokerChannel', () => { ); mcpService.servers.set([server], undefined); - const tools = await channel.call(undefined, 'listTools'); + const tools = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); // Server started during the grace period; tools are now available. assert.strictEqual(server.startCalls, 1); @@ -155,7 +155,7 @@ suite('McpGatewayToolBrokerChannel', () => { ); mcpService.servers.set([server], undefined); - const tools = await channel.call(undefined, 'listTools'); + const tools = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); // Outdated server gets the same grace period as Unknown — started and tools returned. assert.strictEqual(server.startCalls, 1); @@ -177,11 +177,11 @@ suite('McpGatewayToolBrokerChannel', () => { mcpService.servers.set([server], undefined); // First call: waits up to the grace period, server never starts → empty result. - const tools = await channel.call(undefined, 'listTools'); + const tools = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); assert.deepStrictEqual(tools, []); // Second call: grace-period promise already resolved; returns immediately without re-waiting. - const tools2 = await channel.call(undefined, 'listTools'); + const tools2 = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); assert.deepStrictEqual(tools2, []); channel.dispose(); @@ -202,7 +202,7 @@ suite('McpGatewayToolBrokerChannel', () => { mcpService.servers.set([server], undefined); // First call: grace period elapses, server never starts → empty. - const tools1 = await channel.call(undefined, 'listTools'); + const tools1 = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); assert.deepStrictEqual(tools1, []); assert.strictEqual(server.startCalls, 1); @@ -214,7 +214,7 @@ suite('McpGatewayToolBrokerChannel', () => { // Second call: stale grace entry should be discarded, a new grace race starts, // and the server successfully starts → tools returned. - const tools2 = await channel.call(undefined, 'listTools'); + const tools2 = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); assert.deepStrictEqual(tools2.map(t => t.name), ['echo']); assert.strictEqual(server.startCalls, 2); @@ -237,19 +237,71 @@ suite('McpGatewayToolBrokerChannel', () => { mcpService.servers.set([server], undefined); // First call: server starts successfully during grace period. - const tools1 = await channel.call(undefined, 'listTools'); + const tools1 = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); assert.deepStrictEqual(tools1.map(t => t.name), ['echo']); assert.strictEqual(server.startCalls, 1); // Second call: cacheState is now Live (server started), grace entry should NOT // be invalidated, so no additional start call is made. - const tools2 = await channel.call(undefined, 'listTools'); + const tools2 = await channel.call(undefined, 'listToolsForServer', { serverId: 'serverA' }); assert.deepStrictEqual(tools2.map(t => t.name), ['echo']); assert.strictEqual(server.startCalls, 1); channel.dispose(); }); }); + + test('listServers returns all servers regardless of cache state', async () => { + const mcpService = new TestMcpService(); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); + + const liveServer = createServer('collectionA', 'serverA', [], McpServerCacheState.Live); + const unknownServer = createServer('collectionB', 'serverB', [], McpServerCacheState.Unknown); + + mcpService.servers.set([liveServer, unknownServer], undefined); + + const servers = await channel.call(undefined, 'listServers'); + assert.deepStrictEqual(servers, [ + { id: 'serverA', label: 'serverA' }, + { id: 'serverB', label: 'serverB' }, + ]); + + channel.dispose(); + }); + + test('emits onDidChangeServers with descriptors when servers change', () => { + const mcpService = new TestMcpService(); + const channel = new McpGatewayToolBrokerChannel(mcpService, new NullLogService()); + const serverA = createServer('collectionA', 'serverA', []); + + mcpService.servers.set([serverA], undefined); + + const received: (readonly IMcpGatewayServerDescriptor[])[] = []; + const disposable = channel.listen(undefined, 'onDidChangeServers')(e => { + received.push(e); + }); + + // Add a second server + const serverB = createServer('collectionB', 'serverB', []); + mcpService.servers.set([serverA, serverB], undefined); + + assert.strictEqual(received.length, 1); + assert.deepStrictEqual(received[0], [ + { id: 'serverA', label: 'serverA' }, + { id: 'serverB', label: 'serverB' }, + ]); + + // Remove the first server + mcpService.servers.set([serverB], undefined); + + assert.strictEqual(received.length, 2); + assert.deepStrictEqual(received[1], [ + { id: 'serverB', label: 'serverB' }, + ]); + + disposable.dispose(); + channel.dispose(); + }); }); function createServer( diff --git a/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts b/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts index 5fa6524edda..04a50dd823c 100644 --- a/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts +++ b/src/vscode-dts/vscode.proposed.mcpServerDefinitions.d.ts @@ -3,21 +3,45 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +// version: 1 + declare module 'vscode' { // https://github.com/microsoft/vscode/issues/288777 @DonJayamanne + /** + * Represents a single MCP server exposed by the gateway via its own HTTP endpoint. + */ + export interface McpGatewayServer { + /** + * The human-readable label of the MCP server. + */ + readonly label: string; + + /** + * The address of the HTTP MCP server endpoint. + * External processes can connect to this URI to interact with this MCP server. + */ + readonly address: Uri; + } + /** * Represents an MCP gateway that exposes MCP servers via HTTP. - * The gateway provides an HTTP endpoint that external processes can connect - * to in order to interact with MCP servers known to the editor. + * Each known MCP server gets its own HTTP endpoint. The gateway + * dynamically tracks server additions and removals. */ export interface McpGateway extends Disposable { /** - * The address of the HTTP MCP server endpoint. - * External processes can connect to this URI to interact with MCP servers. + * The MCP servers currently exposed by the gateway. + * Each server has its own HTTP endpoint address. */ - readonly address: Uri; + readonly servers: readonly McpGatewayServer[]; + + /** + * Event that fires when the set of gateway servers changes. + * This can be due to MCP servers being added, removed, or restarted. + */ + readonly onDidChangeServers: Event; } /** @@ -42,14 +66,16 @@ declare module 'vscode' { export const onDidChangeMcpServerDefinitions: Event; /** - * Starts an MCP gateway that exposes MCP servers via an HTTP endpoint. + * Starts an MCP gateway that exposes MCP servers via HTTP endpoints. * - * The gateway creates a localhost HTTP server that external processes (such as - * CLI-based agent loops) can connect to in order to interact with MCP servers - * that the editor knows about. + * The gateway creates a localhost HTTP server where each MCP server known + * to the editor gets its own endpoint. External processes (such as CLI-based + * agent loops) can connect to these endpoints to interact with individual + * MCP servers. * * The HTTP server is shared among all gateways and is automatically torn down - * when the last gateway is disposed. + * when the last gateway is disposed. The gateway dynamically tracks server + * additions and removals via {@link McpGateway.onDidChangeServers}. * * @returns A promise that resolves to an {@link McpGateway} if successful, * or `undefined` if no Node process is available (e.g., in serverless web environments).