mcp: make gateway spawn a gateway per mcp rather than a combined gateway (#301741)

* mcp: make gateway spawn a gateway per mcp rather than a combined gateway

Closes https://github.com/microsoft/vscode/issues/301013

Usage is not terrible:

```ts
const disposable = vscode.commands.registerCommand('extension.helloWorld', async() => {
	const gateway = await vscode.lm.startMcpGateway();
	gateway?.onDidChangeServers((e) => {
		console.log('new gateways:', gateway?.servers.map(s => [s.label, s.address.toString()]));
	});
	console.log('initial gateways:', gateway?.servers.map(s => [s.label, s.address.toString()]));
});
```

* pr comments

* tests
This commit is contained in:
Connor Peet
2026-03-17 17:08:09 -07:00
committed by GitHub
parent 3ad62b41a1
commit d87bb196e4
15 changed files with 744 additions and 469 deletions
@@ -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',
+60 -28
View File
@@ -13,39 +13,61 @@ export const IMcpGatewayService = createDecorator<IMcpGatewayService>('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<void>;
readonly onDidChangeResources: Event<void>;
listTools(): Promise<readonly MCP.Tool[]>;
callTool(name: string, args: Record<string, unknown>): Promise<IGatewayCallToolResult>;
listResources(): Promise<readonly IGatewayServerResources[]>;
readResource(serverIndex: number, uri: string): Promise<MCP.ReadResourceResult>;
listResourceTemplates(): Promise<readonly IGatewayServerResourceTemplates[]>;
/**
* 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<void>;
readonly onDidChangeResources: Event<void>;
listTools(): Promise<readonly MCP.Tool[]>;
callTool(name: string, args: Record<string, unknown>): Promise<MCP.CallToolResult>;
listResources(): Promise<readonly MCP.Resource[]>;
readResource(uri: string): Promise<MCP.ReadResourceResult>;
listResourceTemplates(): Promise<readonly MCP.ResourceTemplate[]>;
}
/**
* 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 IMcpGatewayServerDescriptor[]>;
readonly onDidChangeTools: Event<void>;
readonly onDidChangeResources: Event<void>;
listServers(): readonly IMcpGatewayServerDescriptor[];
listToolsForServer(serverId: string): Promise<readonly MCP.Tool[]>;
callToolForServer(serverId: string, name: string, args: Record<string, unknown>): Promise<MCP.CallToolResult>;
listResourcesForServer(serverId: string): Promise<readonly MCP.Resource[]>;
readResourceForServer(serverId: string, uri: string): Promise<MCP.ReadResourceResult>;
listResourceTemplatesForServer(serverId: string): Promise<readonly MCP.ResourceTemplate[]>;
}
/**
* 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<readonly IMcpGatewayServerInfo[]>;
}
/**
* Service that manages MCP gateway HTTP endpoints in the main process (or remote server).
*
+69 -14
View File
@@ -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<TContext> extends Disposable implements IServerChannel<TContext> {
private readonly _onDidChangeGatewayServers = this._register(new Emitter<{ gatewayId: string; servers: readonly IMcpGatewayServerInfo[] }>());
private readonly _gatewayDisposables = this._register(new DisposableMap<string, DisposableStore>());
/** Tracks which gateways belong to which client for cleanup on disconnect */
private readonly _clientGateways = new Map<TContext, Set<string>>();
constructor(
private readonly _ipcServer: IPCServer<TContext>,
@IMcpGatewayService private readonly mcpGatewayService: IMcpGatewayService,
@@ -27,11 +32,23 @@ export class McpGatewayChannel<TContext> 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<T>(_ctx: TContext, _event: string): Event<T> {
throw new Error('Invalid listen');
listen<T>(_ctx: TContext, event: string): Event<T> {
if (event === 'onDidChangeGatewayServers') {
return this._onDidChangeGatewayServers.event as Event<T>;
}
throw new Error(`Invalid listen: ${event}`);
}
async call<T>(ctx: TContext, command: string, args?: unknown): Promise<T> {
@@ -41,21 +58,59 @@ export class McpGatewayChannel<TContext> 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<readonly IMcpGatewayServerDescriptor[]>('listServers');
const onDidChangeServersListener = brokerChannel.listen<readonly IMcpGatewayServerDescriptor[]>('onDidChangeServers');
const result = await this.mcpGatewayService.createGateway(ctx, {
onDidChangeServers: Event.map(onDidChangeServersListener, servers => {
currentServers = servers;
return servers;
}),
onDidChangeTools: brokerChannel.listen<void>('onDidChangeTools'),
onDidChangeResources: brokerChannel.listen<void>('onDidChangeResources'),
listTools: () => brokerChannel.call<readonly MCP.Tool[]>('listTools'),
callTool: (name, callArgs) => brokerChannel.call<IGatewayCallToolResult>('callTool', { name, args: callArgs }),
listResources: () => brokerChannel.call<readonly IGatewayServerResources[]>('listResources'),
readResource: (serverIndex, uri) => brokerChannel.call<MCP.ReadResourceResult>('readResource', { serverIndex, uri }),
listResourceTemplates: () => brokerChannel.call<readonly IGatewayServerResourceTemplates[]>('listResourceTemplates'),
listServers: () => currentServers,
listToolsForServer: serverId => brokerChannel.call<readonly MCP.Tool[]>('listToolsForServer', { serverId }),
callToolForServer: (serverId, name, callArgs) => brokerChannel.call<MCP.CallToolResult>('callToolForServer', { serverId, name, args: callArgs }),
listResourcesForServer: serverId => brokerChannel.call<readonly MCP.Resource[]>('listResourcesForServer', { serverId }),
readResourceForServer: (serverId, uri) => brokerChannel.call<MCP.ReadResourceResult>('readResourceForServer', { serverId, uri }),
listResourceTemplatesForServer: serverId => brokerChannel.call<readonly MCP.ResourceTemplate[]>('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<string>();
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;
}
}
+212 -61
View File
@@ -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<string, McpGatewayRoute>();
/** All active routes keyed by their route UUID */
private readonly _routes = new Map<string, McpGatewayRoute>();
/** Maps gatewayId → set of route UUIDs belonging to that gateway */
private readonly _gatewayRoutes = new Map<string, Set<string>>();
/** Maps gatewayId → serverId → routeId for reverse lookup */
private readonly _gatewayServerRoutes = new Map<string, Map<string, string>>();
/** Maps gatewayId to clientId for tracking ownership */
private readonly _gatewayToClient = new Map<string, unknown>();
/** Per-gateway disposables (e.g. event listeners) */
private readonly _gatewayDisposables = new Map<string, DisposableStore>();
private _serverStartPromise: Promise<void> | 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<string>();
const serverRouteMap = new Map<string, string>();
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<readonly IMcpGatewayServerInfo[]>());
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<string>,
serverRouteMap: Map<string, string>,
onDidChangeServers: Emitter<readonly IMcpGatewayServerInfo[]>,
): 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<string>,
serverRouteMap: Map<string, string>,
): 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<void> {
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<string, McpGatewaySession>();
@@ -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));
}
+20 -99
View File
@@ -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<http.ServerResponse>();
@@ -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<MCP.ListToolsResult> {
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<MCP.ListResourcesResult> {
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<MCP.ReadResourceResult> {
@@ -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<MCP.ListResourceTemplatesResult> {
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[] };
}
}
@@ -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<string, unknown>) => ({
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'));
});
});
+10 -5
View File
@@ -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<readonly McpServerDefinition[]>;
dispose(): void;
}>());
private readonly _gateways = this._register(new DisposableMap<string, IMcpGatewayResult>());
private readonly _gateways = this._register(new DisposableMap<string, DisposableStore>());
constructor(
private readonly _extHostContext: IExtHostContext,
@@ -401,7 +401,7 @@ export class MainThreadMcp extends Disposable implements MainThreadMcpShape {
this._telemetryService.publicLog2<IAuthMetadataSource, McpAuthSetupClassification>('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,
};
}
@@ -3425,6 +3425,7 @@ export interface ExtHostMcpShape {
$sendMessage(id: number, message: string): void;
$waitForInitialCollectionProviders(): Promise<void>;
$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<string | undefined>;
$getTokenForProviderId(id: number, providerId: string, scopes: string[], options?: IMcpAuthenticationOptions): Promise<string | undefined>;
$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;
}
+34 -3
View File
@@ -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<vscode.McpGateway | undefined>;
}
@@ -80,6 +80,12 @@ export class ExtHostMcpService extends Disposable implements IExtHostMpcService
readonly onDidChangeMcpServerDefinitions: Event<void> = this._onDidChangeMcpServerDefinitions.event;
private _mcpServerDefinitions: readonly vscode.McpServerDefinition[] = [];
// Active gateways with their server emitters for dynamic updates
private readonly _activeGateways = new Map<string, {
servers: vscode.McpGatewayServer[];
onDidChangeServers: Emitter<readonly vscode.McpGatewayServer[]>;
}>();
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<readonly vscode.McpGatewayServer[]>();
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 {
@@ -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<IMcpGatewayService>(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) }));
}
@@ -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>('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<readonly IMcpGatewayResultServer[]>;
}
/**
@@ -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<string, unknown>;
}
interface IReadResourceArgs {
serverIndex: number;
interface IReadResourceForServerArgs {
serverId: string;
uri: string;
}
interface IServerIdArg {
serverId: string;
}
export class McpGatewayToolBrokerChannel extends Disposable implements IServerChannel<unknown> {
private readonly _onDidChangeTools = this._register(new Emitter<void>());
private readonly _onDidChangeResources = this._register(new Emitter<void>());
private readonly _serverIdMap = new Map<string, number>();
private _nextServerIndex = 0;
private readonly _onDidChangeServers = this._register(new Emitter<readonly IMcpGatewayServerDescriptor[]>());
/**
* 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<boolean> {
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<T>;
case 'onDidChangeResources':
return this._onDidChangeResources.event as Event<T>;
case 'onDidChangeServers':
return this._onDidChangeServers.event as Event<T>;
}
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<readonly MCP.Tool[]> {
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<string, unknown>, token: CancellationToken = CancellationToken.None): Promise<IGatewayCallToolResult> {
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<readonly IGatewayServerResources[]> {
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<MCP.ReadResourceResult> {
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<readonly IGatewayServerResourceTemplates[]> {
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<readonly MCP.Tool[]> {
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<string, unknown>, token: CancellationToken = CancellationToken.None): Promise<MCP.CallToolResult> {
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<readonly MCP.Resource[]> {
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<MCP.ReadResourceResult> {
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<readonly MCP.ResourceTemplate[]> {
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<boolean> {
@@ -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<IMcpGatewayService>(
mainProcessService.getChannel(McpGatewayChannelName)
);
this._localChannel = mainProcessService.getChannel(McpGatewayChannelName);
this._localPlatformService = ProxyChannel.toService<IMcpGatewayService>(this._localChannel);
}
async createGateway(inRemote: boolean): Promise<IMcpGatewayResult | undefined> {
@@ -44,11 +45,20 @@ export class WorkbenchMcpGatewayService implements IWorkbenchMcpGatewayService {
private async _createLocalGateway(): Promise<IMcpGatewayResult> {
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<IMcpGatewayService>(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) }));
}
@@ -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<readonly MCP.Tool[]>(undefined, 'listTools');
const names = result.map(tool => tool.name).sort();
const resultA = await channel.call<readonly MCP.Tool[]>(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<readonly MCP.Tool[]>(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<IGatewayCallToolResult>(undefined, 'callTool', {
const resultA = await channel.call<MCP.CallToolResult>(undefined, 'callToolForServer', {
serverId: 'serverA',
name: 'mcp_serverA_echo',
args: { name: 'one' },
});
const resultB = await channel.call<IGatewayCallToolResult>(undefined, 'callTool', {
const resultB = await channel.call<MCP.CallToolResult>(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<readonly MCP.Tool[]>(undefined, 'listTools');
await channel.call<readonly MCP.Tool[]>(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<readonly MCP.Tool[]>(undefined, 'listTools');
const tools = await channel.call<readonly MCP.Tool[]>(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<readonly MCP.Tool[]>(undefined, 'listTools');
const tools = await channel.call<readonly MCP.Tool[]>(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<readonly MCP.Tool[]>(undefined, 'listTools');
const tools = await channel.call<readonly MCP.Tool[]>(undefined, 'listToolsForServer', { serverId: 'serverA' });
assert.deepStrictEqual(tools, []);
// Second call: grace-period promise already resolved; returns immediately without re-waiting.
const tools2 = await channel.call<readonly MCP.Tool[]>(undefined, 'listTools');
const tools2 = await channel.call<readonly MCP.Tool[]>(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<readonly MCP.Tool[]>(undefined, 'listTools');
const tools1 = await channel.call<readonly MCP.Tool[]>(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<readonly MCP.Tool[]>(undefined, 'listTools');
const tools2 = await channel.call<readonly MCP.Tool[]>(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<readonly MCP.Tool[]>(undefined, 'listTools');
const tools1 = await channel.call<readonly MCP.Tool[]>(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<readonly MCP.Tool[]>(undefined, 'listTools');
const tools2 = await channel.call<readonly MCP.Tool[]>(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<readonly IMcpGatewayServerDescriptor[]>(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<readonly IMcpGatewayServerDescriptor[]>(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(
+36 -10
View File
@@ -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<readonly McpGatewayServer[]>;
}
/**
@@ -42,14 +66,16 @@ declare module 'vscode' {
export const onDidChangeMcpServerDefinitions: Event<void>;
/**
* 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).