mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-13 15:35:20 +01:00
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:
@@ -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',
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user