Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/inProcHttpServer.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';6import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';7import type * as express from 'express';8import * as fs from 'fs/promises';9import * as os from 'os';10import * as path from 'path';11import * as vscode from 'vscode';12import { ILogger } from '../../../../platform/log/common/logService';13import { Disposable, toDisposable } from '../../../../util/vs/base/common/lifecycle';14import { generateUuid } from '../../../../util/vs/base/common/uuid';15import { ICopilotCLISessionTracker } from './copilotCLISessionTracker';1617interface McpProviderOptions {18id: string;19serverLabel: string;20serverVersion: string;21registerTools: (server: McpServer, sessionId: string) => Promise<void> | void;22registerPushNotifications?: () => Promise<void> | void;23}2425class AsyncLazy<T> {26private _value: T | undefined;27private _promise: Promise<T> | undefined;2829constructor(private readonly factory: () => Promise<T>) { }3031get value(): Promise<T> {32if (this._value !== undefined) {33return Promise.resolve(this._value);34}3536if (this._promise) {37return this._promise;38}3940this._promise = this.factory().then(value => {41this._value = value;42return value;43});4445return this._promise;46}47}4849export class InProcHttpServer extends Disposable {50private readonly _transports: Record<string, StreamableHTTPServerTransport> = {};51private readonly _onDidClientConnect = this._register(new vscode.EventEmitter<string>());52public readonly onDidClientConnect = this._onDidClientConnect.event;53private readonly _onDidClientDisconnect = this._register(new vscode.EventEmitter<string>());54public readonly onDidClientDisconnect = this._onDidClientDisconnect.event;55constructor(56private readonly _logger: ILogger,57private readonly _sessionTracker: ICopilotCLISessionTracker,58) {59super();60}6162broadcastNotification(method: string, params: Record<string, unknown>): void {63const message = {64jsonrpc: '2.0' as const,65method,66params,67};6869for (const sessionId in this._transports) {70this._transports[sessionId].send(message).catch(() => {71this._logger.debug(`Failed to send notification "${method}" to client ${sessionId}`);72});73}74}7576sendNotification(sessionId: string, method: string, params: Record<string, unknown>): void {77const transport = this._getTransport(sessionId);78if (!transport) {79this._logger.debug(`Cannot send notification "${method}": session ${sessionId} not found`);80return;81}8283const message = {84jsonrpc: '2.0' as const,85method,86params,87};8889transport.send(message).catch(() => {90this._logger.debug(`Failed to send notification "${method}" to client ${sessionId}`);91});92}9394getConnectedSessionIds(): readonly string[] {95return Object.keys(this._transports);96}9798async start(99mcpOptions: McpProviderOptions,100): Promise<{ serverUri: vscode.Uri; headers: Record<string, string> }> {101let socketPath: string | undefined;102103this._logger.debug(`Starting MCP HTTP server for ${mcpOptions.serverLabel}...`);104105try {106const nonce = generateUuid();107socketPath = await getRandomSocketPath();108this._logger.trace(`Generated socket path: ${socketPath}`);109110const expressModule = (await expressLazy.value) as unknown as {111default?: typeof import('express');112} & typeof import('express');113const expressApp = expressModule.default || expressModule;114115const app: express.Application = (expressApp as () => express.Application)();116117// MCP requests like open_diff include full file contents which can exceed the default ~100KB limit118app.use(expressApp.json({ limit: '10mb' }));119app.use((req: express.Request, res: express.Response, next: express.NextFunction) =>120this._authMiddleware(nonce, req, res, next),121);122123app.post('/mcp', (req: express.Request, res: express.Response) => this._handlePost(mcpOptions, req, res));124app.get('/mcp', (req: express.Request, res: express.Response) => this._handleGetDelete(req, res));125app.delete('/mcp', (req: express.Request, res: express.Response) => this._handleGetDelete(req, res));126127const httpServer = app.listen(socketPath);128this._logger.debug('HTTP server listening on socket');129130// Register push notifications if provided131if (mcpOptions.registerPushNotifications) {132this._logger.debug('Registering push notifications...');133await Promise.resolve(mcpOptions.registerPushNotifications());134}135136this._register(toDisposable(() => {137this._logger.info('Shutting down MCP server...');138for (const sessionId in this._transports) {139void this._transports[sessionId].close();140this._unregisterTransport(sessionId);141}142143if (httpServer.listening) {144httpServer.close();145httpServer.closeAllConnections();146}147148void tryCleanupSocket(socketPath);149this._logger.debug('MCP server shutdown complete');150}));151return {152serverUri: vscode.Uri.from({153scheme: os.platform() === 'win32' ? 'pipe' : 'unix',154path: socketPath,155fragment: '/mcp',156}),157headers: {158Authorization: `Nonce ${nonce}`,159},160};161} catch (err) {162void tryCleanupSocket(socketPath);163throw err;164}165}166167private _registerTransport(sessionId: string, transport: StreamableHTTPServerTransport): void {168this._transports[sessionId] = transport;169this._onDidClientConnect.fire(sessionId);170this._logger.info(`Client connected: ${sessionId}`);171}172173private _unregisterTransport(sessionId: string): void {174delete this._transports[sessionId];175this._onDidClientDisconnect.fire(sessionId);176this._logger.info(`Client disconnected: ${sessionId}`);177}178179private _getTransport(sessionId: string): StreamableHTTPServerTransport | undefined {180return this._transports[sessionId];181}182183private _authMiddleware(nonce: string, req: express.Request, res: express.Response, next: express.NextFunction): void {184if (req.headers.authorization !== `Nonce ${nonce}`) {185this._logger.debug(`Unauthorized request to ${req.method} ${req.path}`);186res.status(401).send('Unauthorized');187return;188}189190next();191}192193private async _handlePost(mcpOptions: McpProviderOptions, req: express.Request, res: express.Response): Promise<void> {194const sessionId = req.headers['mcp-session-id'] ?? req.headers['x-copilot-session-id'];195if (Array.isArray(sessionId) || !sessionId || typeof sessionId !== 'string') {196res.status(400).json({197jsonrpc: '2.0',198error: { code: -32000, message: 'Bad Request: Session ID must be a single, defined, string value' },199id: null,200});201return;202}203this._logger.trace(`POST /mcp request, sessionId: ${sessionId ?? '(none)'}`);204205const isInitializeRequest = await isInitializeRequestLazy.value;206const { StreamableHTTPServerTransport } = await streamableHttpLazy.value;207208let transport: StreamableHTTPServerTransport;209const existingTransport = sessionId ? this._getTransport(sessionId) : undefined;210if (sessionId && existingTransport) {211if (isInitializeRequest(req.body)) {212this._logger.debug(`Rejecting duplicate initialize for session ${sessionId}`);213res.status(409).json({214jsonrpc: '2.0',215error: {216code: -32000,217message: 'Conflict: A connection for this session already exists',218},219id: null,220});221return;222}223transport = existingTransport;224} else if (sessionId && isInitializeRequest(req.body)) {225this._logger.debug('Creating new MCP session...');226const clientPid = parseInt(req.headers['x-copilot-pid'] as string, 10);227const clientPpid = parseInt(req.headers['x-copilot-parent-pid'] as string, 10);228let sessionRegistration: { dispose(): void } | undefined;229transport = new StreamableHTTPServerTransport({230sessionIdGenerator: () => sessionId,231onsessioninitialized: (mcpSessionId) => {232this._registerTransport(mcpSessionId, transport);233if (!isNaN(clientPid) && !isNaN(clientPpid)) {234sessionRegistration = this._sessionTracker.registerSession(mcpSessionId, { pid: clientPid, ppid: clientPpid });235}236},237onsessionclosed: closedSessionId => {238this._unregisterTransport(closedSessionId);239sessionRegistration?.dispose();240},241enableDnsRebindingProtection: true,242allowedHosts: ['localhost'],243});244245const { McpServer } = await mcpServerLazy.value;246const server = new McpServer({247name: mcpOptions.id,248title: mcpOptions.serverLabel,249version: mcpOptions.serverVersion,250});251252try {253this._logger.debug('Registering MCP tools...');254await Promise.resolve(mcpOptions.registerTools(server, sessionId));255} catch (err) {256const errMsg = err instanceof Error ? err.message : String(err);257this._logger.error(`Failed to register MCP tools: ${errMsg}`);258res.status(500).json({259jsonrpc: '2.0',260error: {261code: -32000,262message: `Failed to register MCP tools: ${errMsg}`,263},264id: null,265});266return;267}268269await server.connect(transport);270} else {271this._logger.debug('Bad request: No valid session ID provided');272res.status(400).json({273jsonrpc: '2.0',274error: {275code: -32000,276message: 'Bad Request: No valid session ID provided',277},278id: null,279});280return;281}282283await transport.handleRequest(req, res, req.body);284}285286private async _handleGetDelete(req: express.Request, res: express.Response): Promise<void> {287const sessionId = req.headers['mcp-session-id'] as string | undefined;288this._logger.trace(`${req.method} /mcp request, sessionId: ${sessionId ?? '(none)'}`);289290const transport = sessionId ? this._getTransport(sessionId) : undefined;291if (!sessionId || !transport) {292this._logger.debug(`Invalid or missing session ID for ${req.method} request`);293res.status(400).send('Invalid or missing session ID');294return;295}296297await transport.handleRequest(req, res);298}299}300301async function getRandomSocketPath(): Promise<string> {302if (os.platform() === 'win32') {303return `\\\\.\\pipe\\mcp-${generateUuid()}.sock`;304} else {305const prefix = path.join(os.tmpdir(), 'mcp-');306const tempDir = await fs.mkdtemp(prefix);307await fs.chmod(tempDir, 0o700);308return path.join(tempDir, 'mcp.sock');309}310}311312async function tryCleanupSocket(socketPath: string | undefined): Promise<void> {313try {314if (os.platform() === 'win32') {315return;316}317318if (!socketPath) {319return;320}321322const dir = path.dirname(socketPath);323await fs.rm(dir, { recursive: true, force: true });324} catch {325// Best effort326}327}328329const expressLazy = new AsyncLazy(async () => await import('express'));330const streamableHttpLazy = new AsyncLazy(async () => await import('@modelcontextprotocol/sdk/server/streamableHttp.js'));331const mcpServerLazy = new AsyncLazy(async () => await import('@modelcontextprotocol/sdk/server/mcp.js'));332const isInitializeRequestLazy = new AsyncLazy(async () => {333const { isInitializeRequest } = await import('@modelcontextprotocol/sdk/types.js');334return isInitializeRequest;335});336337338