Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 20 additions & 17 deletions src/vs/platform/mcp/node/mcpGatewayService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { JsonRpcMessage, JsonRpcProtocol } from '../../../base/common/jsonRpcPro
import { Disposable } from '../../../base/common/lifecycle.js';
import { URI } from '../../../base/common/uri.js';
import { generateUuid } from '../../../base/common/uuid.js';
import { ILogService } from '../../log/common/log.js';
import { ILogger, ILoggerService } from '../../log/common/log.js';
import { IMcpGatewayInfo, IMcpGatewayService, IMcpGatewayToolInvoker } from '../common/mcpGateway.js';
import { isInitializeMessage, McpGatewaySession } from './mcpGatewaySession.js';

Expand All @@ -28,11 +28,14 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService
/** Maps gatewayId to clientId for tracking ownership */
private readonly _gatewayToClient = new Map<string, unknown>();
private _serverStartPromise: Promise<void> | undefined;
private readonly _logger: ILogger;

constructor(
@ILogService private readonly _logService: ILogService,
@ILoggerService loggerService: ILoggerService,
) {
super();
this._logger = this._register(loggerService.createLogger('mcpGateway', { name: 'MCP Gateway', logLevel: 'always' }));
this._logger.info('[McpGatewayService] Initialized');
}

async createGateway(clientId: unknown, toolInvoker?: IMcpGatewayToolInvoker): Promise<IMcpGatewayInfo> {
Expand All @@ -51,15 +54,15 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService
throw new Error('[McpGatewayService] Tool invoker is required to create gateway');
}

const gateway = new McpGatewayRoute(gatewayId, this._logService, toolInvoker);
const gateway = new McpGatewayRoute(gatewayId, this._logger, toolInvoker);
this._gateways.set(gatewayId, gateway);

// Track client ownership if clientId provided (for cleanup on disconnect)
if (clientId) {
this._gatewayToClient.set(gatewayId, clientId);
this._logService.info(`[McpGatewayService] Created gateway at http://127.0.0.1:${this._port}/gateway/${gatewayId} for client ${clientId}`);
this._logger.info(`[McpGatewayService] Created gateway at http://127.0.0.1:${this._port}/gateway/${gatewayId} for client ${clientId}`);
} else {
this._logService.warn(`[McpGatewayService] Created gateway without client tracking at http://127.0.0.1:${this._port}/gateway/${gatewayId}`);
this._logger.warn(`[McpGatewayService] Created gateway without client tracking at http://127.0.0.1:${this._port}/gateway/${gatewayId}`);
}

const address = URI.parse(`http://127.0.0.1:${this._port}/gateway/${gatewayId}`);
Expand All @@ -73,14 +76,14 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService
async disposeGateway(gatewayId: string): Promise<void> {
const gateway = this._gateways.get(gatewayId);
if (!gateway) {
this._logService.warn(`[McpGatewayService] Attempted to dispose unknown gateway: ${gatewayId}`);
this._logger.warn(`[McpGatewayService] Attempted to dispose unknown gateway: ${gatewayId}`);
return;
}

gateway.dispose();
this._gateways.delete(gatewayId);
this._gatewayToClient.delete(gatewayId);
this._logService.info(`[McpGatewayService] Disposed gateway: ${gatewayId}`);
this._logger.info(`[McpGatewayService] Disposed gateway: ${gatewayId}`);

// If no more gateways, shut down the server
if (this._gateways.size === 0) {
Expand All @@ -98,7 +101,7 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService
}

if (gatewaysToDispose.length > 0) {
this._logService.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}`);
this._logger.info(`[McpGatewayService] Disposing ${gatewaysToDispose.length} gateway(s) for disconnected client ${clientId}`);

for (const gatewayId of gatewaysToDispose) {
this._gateways.get(gatewayId)?.dispose();
Expand Down Expand Up @@ -156,19 +159,19 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService
}

clearTimeout(portTimeout);
this._logService.info(`[McpGatewayService] Server started on port ${this._port}`);
this._logger.info(`[McpGatewayService] Server started on port ${this._port}`);
deferredPromise.complete();
});

this._server.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
this._logService.warn('[McpGatewayService] Port in use, retrying with random port...');
this._logger.warn('[McpGatewayService] Port in use, retrying with random port...');
// Try with a random port
this._server!.listen(0, '127.0.0.1');
return;
}
clearTimeout(portTimeout);
this._logService.error(`[McpGatewayService] Server error: ${err}`);
this._logger.error(`[McpGatewayService] Server error: ${err}`);
deferredPromise.error(err);
});

Expand All @@ -183,13 +186,13 @@ export class McpGatewayService extends Disposable implements IMcpGatewayService
return;
}

this._logService.info('[McpGatewayService] Stopping server (no more gateways)');
this._logger.info('[McpGatewayService] Stopping server (no more gateways)');

this._server.close(err => {
if (err) {
this._logService.error(`[McpGatewayService] Error closing server: ${err}`);
this._logger.error(`[McpGatewayService] Error closing server: ${err}`);
} else {
this._logService.info('[McpGatewayService] Server stopped');
this._logger.info('[McpGatewayService] Server stopped');
}
});

Expand Down Expand Up @@ -237,7 +240,7 @@ class McpGatewayRoute extends Disposable {

constructor(
public readonly gatewayId: string,
private readonly _logService: ILogService,
private readonly _logger: ILogger,
private readonly _toolInvoker: IMcpGatewayToolInvoker,
) {
super();
Expand Down Expand Up @@ -344,7 +347,7 @@ class McpGatewayRoute extends Disposable {
res.writeHead(200, headers);
res.end(JSON.stringify(Array.isArray(message) ? responses : responses[0]));
} catch (error) {
this._logService.error('[McpGatewayService] Failed handling gateway request', error);
this._logger.error('[McpGatewayService] Failed handling gateway request', error);
this._respondHttpError(res, 500, 'Internal server error');
}
}
Expand All @@ -366,7 +369,7 @@ class McpGatewayRoute extends Disposable {
}

const sessionId = generateUuid();
const session = new McpGatewaySession(sessionId, this._logService, () => {
const session = new McpGatewaySession(sessionId, this._logger, () => {
this._sessions.delete(sessionId);
}, this._toolInvoker);
this._sessions.set(sessionId, session);
Expand Down
29 changes: 24 additions & 5 deletions src/vs/platform/mcp/node/mcpGatewaySession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ import {
} from '../../../base/common/jsonRpcProtocol.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { hasKey } from '../../../base/common/types.js';
import { ILogService } from '../../log/common/log.js';
import { ILogger } from '../../log/common/log.js';
import { IMcpGatewayToolInvoker } from '../common/mcpGateway.js';
import { MCP } from '../common/modelContextProtocol.js';

const MCP_LATEST_PROTOCOL_VERSION = '2025-11-25';
const MCP_SUPPORTED_PROTOCOL_VERSIONS = [
'2025-11-25',
'2025-06-18',
'2025-03-26',
'2024-11-05',
'2024-10-07',
];
const MCP_INVALID_REQUEST = -32600;
const MCP_METHOD_NOT_FOUND = -32601;
const MCP_INVALID_PARAMS = -32602;
Expand Down Expand Up @@ -79,7 +86,7 @@ export class McpGatewaySession extends Disposable {

constructor(
public readonly id: string,
private readonly _logService: ILogService,
private readonly _logService: ILogger,
private readonly _onDidDispose: () => void,
private readonly _toolInvoker: IMcpGatewayToolInvoker,
) {
Expand Down Expand Up @@ -192,7 +199,7 @@ export class McpGatewaySession extends Disposable {

private async _handleRequest(request: IJsonRpcRequest): Promise<unknown> {
if (request.method === 'initialize') {
return this._handleInitialize();
return this._handleInitialize(request);
}

if (!this._isInitialized) {
Expand Down Expand Up @@ -225,9 +232,21 @@ export class McpGatewaySession extends Disposable {
}
}

private _handleInitialize(): MCP.InitializeResult {
private _handleInitialize(request: IJsonRpcRequest): MCP.InitializeResult {
const params = typeof request.params === 'object' && request.params ? request.params as Record<string, unknown> : undefined;
const clientVersion = typeof params?.protocolVersion === 'string' ? params.protocolVersion : undefined;
const clientInfo = params?.clientInfo as { name?: string; version?: string } | undefined;
const negotiatedVersion = clientVersion && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)
? clientVersion
: MCP_LATEST_PROTOCOL_VERSION;

this._logService.info(`[McpGateway] Initialize: client=${clientInfo?.name ?? 'unknown'}/${clientInfo?.version ?? '?'}, clientProtocol=${clientVersion ?? '(none)'}, negotiated=${negotiatedVersion}`);
if (clientVersion && clientVersion !== negotiatedVersion) {
this._logService.warn(`[McpGateway] Client requested unsupported protocol version '${clientVersion}', falling back to '${negotiatedVersion}'`);
}

return {
protocolVersion: MCP_LATEST_PROTOCOL_VERSION,
protocolVersion: negotiatedVersion,
capabilities: {
tools: {
listChanged: true,
Expand Down
139 changes: 139 additions & 0 deletions src/vs/platform/mcp/test/node/mcpGatewaySession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,145 @@ suite('McpGatewaySession', () => {
onDidChangeResources.dispose();
});

test('negotiates to older protocol version when client requests it', async () => {
const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker();
const session = new McpGatewaySession('session-negotiate-1', new NullLogService(), () => { }, invoker);

const responses = await session.handleIncoming({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2025-03-26',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
});

assert.strictEqual(responses.length, 1);
const response = responses[0] as IJsonRpcSuccessResponse;
assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-03-26');
session.dispose();
onDidChangeTools.dispose();
onDidChangeResources.dispose();
});

test('negotiates to each supported protocol version', async () => {
const supportedVersions = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07'];
for (const version of supportedVersions) {
const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker();
const session = new McpGatewaySession(`session-ver-${version}`, new NullLogService(), () => { }, invoker);

const responses = await session.handleIncoming({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: version, capabilities: {} },
});

const response = responses[0] as IJsonRpcSuccessResponse;
assert.strictEqual(
(response.result as { protocolVersion: string }).protocolVersion,
version,
`Expected server to negotiate to ${version}`
);
session.dispose();
onDidChangeTools.dispose();
onDidChangeResources.dispose();
}
});

test('falls back to latest version for unsupported client version', async () => {
const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker();
const session = new McpGatewaySession('session-negotiate-2', new NullLogService(), () => { }, invoker);

const responses = await session.handleIncoming({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2099-01-01',
capabilities: {},
clientInfo: { name: 'test-client', version: '1.0.0' },
},
});

assert.strictEqual(responses.length, 1);
const response = responses[0] as IJsonRpcSuccessResponse;
assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25');
session.dispose();
onDidChangeTools.dispose();
onDidChangeResources.dispose();
});

test('falls back to latest version when no params provided', async () => {
const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker();
const session = new McpGatewaySession('session-negotiate-3', new NullLogService(), () => { }, invoker);

const responses = await session.handleIncoming({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
});

assert.strictEqual(responses.length, 1);
const response = responses[0] as IJsonRpcSuccessResponse;
assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25');
session.dispose();
onDidChangeTools.dispose();
onDidChangeResources.dispose();
});

test('falls back to latest version when protocolVersion is not a string', async () => {
const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker();
const session = new McpGatewaySession('session-negotiate-4', new NullLogService(), () => { }, invoker);

const responses = await session.handleIncoming({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: 42,
capabilities: {},
},
});

assert.strictEqual(responses.length, 1);
const response = responses[0] as IJsonRpcSuccessResponse;
assert.strictEqual((response.result as { protocolVersion: string }).protocolVersion, '2025-11-25');
session.dispose();
onDidChangeTools.dispose();
onDidChangeResources.dispose();
});

test('initialize response includes server info and capabilities', async () => {
const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker();
const session = new McpGatewaySession('session-init-caps', new NullLogService(), () => { }, invoker);

const responses = await session.handleIncoming({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-03-26', capabilities: {} },
});

const result = (responses[0] as IJsonRpcSuccessResponse).result as MCP.InitializeResult;
assert.deepStrictEqual(result, {
protocolVersion: '2025-03-26',
capabilities: {
tools: { listChanged: true },
resources: { listChanged: true },
},
serverInfo: {
name: 'VS Code MCP Gateway',
version: '1.0.0',
},
});
session.dispose();
onDidChangeTools.dispose();
onDidChangeResources.dispose();
});

test('rejects non-initialize requests before initialized notification', async () => {
const { invoker, onDidChangeTools, onDidChangeResources } = createInvoker();
const session = new McpGatewaySession('session-2', new NullLogService(), () => { }, invoker);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@ import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyn
import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js';
import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js';
import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js';
import { agentIcon, instructionsIcon, promptIcon, skillIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js';
import { agentIcon, instructionsIcon, mcpServerIcon, promptIcon, skillIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js';
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';
import { IEditorService, MODAL_GROUP } from '../../../../workbench/services/editor/common/editorService.js';
import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js';

const $ = DOM.$;

Expand Down Expand Up @@ -67,6 +68,7 @@ export class AICustomizationOverviewView extends ViewPane {
@IPromptsService private readonly promptsService: IPromptsService,
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
@IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService,
@IMcpService private readonly mcpService: IMcpService,
) {
super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService);

Expand All @@ -76,6 +78,7 @@ export class AICustomizationOverviewView extends ViewPane {
{ id: AICustomizationManagementSection.Skills, label: localize('skills', "Skills"), icon: skillIcon, count: 0 },
{ id: AICustomizationManagementSection.Instructions, label: localize('instructions', "Instructions"), icon: instructionsIcon, count: 0 },
{ id: AICustomizationManagementSection.Prompts, label: localize('prompts', "Prompts"), icon: promptIcon, count: 0 },
{ id: AICustomizationManagementSection.McpServers, label: localize('mcpServers', "MCP Servers"), icon: mcpServerIcon, count: 0 },
);

// Listen to changes
Expand Down Expand Up @@ -173,6 +176,16 @@ export class AICustomizationOverviewView extends ViewPane {
}
}));

// Update MCP server count reactively
const mcpSection = this.sections.find(s => s.id === AICustomizationManagementSection.McpServers);
if (mcpSection) {
this._register(autorun(reader => {
const servers = this.mcpService.servers.read(reader);
mcpSection.count = servers.length;
this.updateCountElements();
}));
}

this.updateCountElements();
}

Expand Down
Loading
Loading