Skip to content
Open
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
152 changes: 139 additions & 13 deletions src/mcp/mcpServer.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,117 @@
import * as http from 'http';
import * as crypto from 'crypto';
import * as vscode from 'vscode';
import path = require('path');
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { gDebugLog, gEdkWorkspaces, gPathFind, gWorkspacePath } from '../extension';
import { gDebugLog, gEdkWorkspaces, gExtensionContext, gPathFind, gWorkspacePath } from '../extension';
import { openTextDocument } from '../utils';
import { getParserForDocument } from '../edkParser/parserFactory';
import { Edk2SymbolType } from '../symbols/symbolsType';
import { z } from 'zod';

let httpServer: http.Server | undefined;
let mcpServer: McpServer | undefined;
let serverToken: string | undefined;
const transports: Record<string, SSEServerTransport> = {};

/** The server is only ever reachable through the loopback interface. */
const BIND_ADDRESS = '127.0.0.1';

/** Host names that are accepted in the `Host` and `Origin` headers. */
const ALLOWED_HOST_NAMES = new Set(['localhost', '127.0.0.1', '::1']);

/**
* Extracts the host name (without port and without IPv6 brackets) from a
* `Host` header value.
*/
function parseHostName(hostHeader: string | undefined): string | undefined {
if (!hostHeader) {
return undefined;
}
const value = hostHeader.trim();
// IPv6 literal, e.g. "[::1]:3100"
if (value.startsWith('[')) {
const end = value.indexOf(']');
return end === -1 ? undefined : value.slice(1, end).toLowerCase();
}
return value.split(':')[0].toLowerCase();
}

/**
* Rejects requests that do not target the loopback interface by name.
* This is what defeats DNS rebinding attacks: the browser keeps sending the
* attacker controlled host name (e.g. "evil.com") in the `Host`/`Origin`
* headers even after the DNS record points at 127.0.0.1.
*/
function isAllowedOriginAndHost(req: http.IncomingMessage): boolean {
const hostName = parseHostName(req.headers.host);
if (!hostName || !ALLOWED_HOST_NAMES.has(hostName)) {
return false;
}

const origin = req.headers.origin;
if (origin !== undefined && origin !== 'null') {
try {
const originHost = new URL(origin).hostname.replace(/^\[|\]$/g, '').toLowerCase();
if (!ALLOWED_HOST_NAMES.has(originHost)) {
return false;
}
} catch {
return false;
}
}

return true;
}

/** Constant time comparison of the presented bearer token. */
function isAuthorized(req: http.IncomingMessage): boolean {
if (!serverToken) {
return false;
}
const header = req.headers.authorization;
if (!header) {
return false;
}
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
if (!match) {
return false;
}
const presented = Buffer.from(match[1], 'utf8');
const expected = Buffer.from(serverToken, 'utf8');
if (presented.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(presented, expected);
}

/** Returns the bearer token required by MCP clients, if the server is running. */
export function getMcpServerToken(): string | undefined {
return serverToken;
}

const TOKEN_SECRET_KEY = 'edk2code.mcpServerToken';

/**
* Returns the persisted access token, creating one on first use.
* The token is kept in VS Code SecretStorage so that MCP clients do not need
* to be reconfigured every time the server restarts.
*/
export async function getOrCreateMcpToken(): Promise<string> {
const secrets = gExtensionContext?.secrets;
if (!secrets) {
// No storage available: fall back to an ephemeral token.
return serverToken ?? crypto.randomBytes(32).toString('hex');
}
Comment on lines +102 to +106
let token = await secrets.get(TOKEN_SECRET_KEY);
if (!token) {
token = crypto.randomBytes(32).toString('hex');
await secrets.store(TOKEN_SECRET_KEY, token);
}
return token;
}

function createMcpServer(): McpServer {
const server = new McpServer(
{ name: 'edk2code', version: '1.0.0' },
Expand Down Expand Up @@ -306,9 +405,34 @@ export async function startMcpServer(port: number): Promise<void> {
}

mcpServer = createMcpServer();
serverToken = await getOrCreateMcpToken();

httpServer = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '', `http://localhost:${port}`);
const url = new URL(req.url ?? '', `http://${BIND_ADDRESS}:${port}`);

// Reject cross-origin / rebound-DNS requests before doing any work.
if (!isAllowedOriginAndHost(req)) {
gDebugLog.warning(
`MCP SSE: rejected request with host "${req.headers.host}" origin "${req.headers.origin}"`
);
res.writeHead(403);
res.end('Forbidden');
return;
}

// Health check (no token required, loopback only).
if (req.method === 'GET' && url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}

if (!isAuthorized(req)) {
gDebugLog.warning('MCP SSE: rejected unauthenticated request');
res.writeHead(401, { 'WWW-Authenticate': 'Bearer' });
res.end('Unauthorized');
return;
}

// SSE stream endpoint
if (req.method === 'GET' && url.pathname === '/sse') {
Expand Down Expand Up @@ -344,29 +468,30 @@ export async function startMcpServer(port: number): Promise<void> {
return;
}

// Health check
if (req.method === 'GET' && url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}

res.writeHead(404);
res.end('Not Found');
});

return new Promise<void>((resolve, reject) => {
httpServer!.listen(port, () => {
gDebugLog.info(`MCP SSE server listening on http://localhost:${port}/sse`);
httpServer!.listen(port, BIND_ADDRESS, () => {
gDebugLog.info(`MCP SSE server listening on http://${BIND_ADDRESS}:${port}/sse (loopback only)`);
vscode.window.showInformationMessage(
`EDK2 MCP SSE server started on http://localhost:${port}/sse`
);
`EDK2 MCP SSE server started on http://${BIND_ADDRESS}:${port}/sse. ` +
'Clients must send an "Authorization: Bearer <token>" header.',
'Copy Access Token'
).then(async (selection) => {
if (selection === 'Copy Access Token' && serverToken) {
await vscode.env.clipboard.writeText(serverToken);
vscode.window.showInformationMessage('EDK2 MCP access token copied to clipboard');
}
});
resolve();
});
httpServer!.on('error', (err) => {
gDebugLog.error(`MCP SSE server error: ${err}`);
vscode.window.showErrorMessage(`Failed to start MCP server: ${err.message}`);
httpServer = undefined;
serverToken = undefined;
reject(err);
});
});
Expand All @@ -384,6 +509,7 @@ export function stopMcpServer(): void {
httpServer.close();
httpServer = undefined;
mcpServer = undefined;
serverToken = undefined;
gDebugLog.info('MCP SSE server stopped');
vscode.window.showInformationMessage('EDK2 MCP SSE server stopped');
}
Expand Down
1 change: 1 addition & 0 deletions src/newVersionPage/2.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ EDK2Code now exposes an **MCP (Model Context Protocol) SSE server** that lets AI

- Start with **`EDK2: Start MCP SSE Server`** and stop with **`EDK2: Stop MCP SSE Server`**.
- The server port is configurable via the `edk2code.mcpServerPort` setting (default `3100`).
- The server listens on the loopback interface (`127.0.0.1`) only, rejects requests whose `Host`/`Origin` headers are not loopback, and requires an `Authorization: Bearer <token>` header. Use **Auto configure** in the settings UI to generate `.vscode/mcp.json`; the access token is copied to your clipboard and VS Code prompts for it.

## Settings UI

Expand Down
60 changes: 44 additions & 16 deletions src/settings/settingsPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Disposable, Webview, WebviewPanel, window, Uri, ViewColumn } from "vsco
import { gExtensionContext } from '../extension';
import { ConfigAgent, WorkspaceConfig, WorkspaceConfigErrors } from '../configuration';
import { askReloadFiles } from '../ui/messages';
import { isMcpServerRunning } from '../mcp/mcpServer';
import { getOrCreateMcpToken, isMcpServerRunning } from '../mcp/mcpServer';


function deepCopy(obj: any) {
Expand Down Expand Up @@ -264,46 +264,74 @@ export class SettingsPanel {
const vscodePath = path.join(workspaceFolders[0].uri.fsPath, '.vscode');
const mcpConfigPath = path.join(vscodePath, 'mcp.json');
const port = vscode.workspace.getConfiguration('edk2code').get<number>('mcpServerPort', 3100);
const expectedUrl = `http://localhost:${port}/sse`;
// The server only listens on the loopback interface.
const expectedUrl = `http://127.0.0.1:${port}/sse`;
const tokenInputId = 'edk2code-mcp-token';
// The token is never written to disk: it is requested from the user and
// kept by VS Code, so that mcp.json can be safely committed.
const tokenInput = {
id: tokenInputId,
type: 'promptString',
description: 'EDK2Code MCP access token',
password: true
};
const serverEntryValue = {
type: 'sse',
url: expectedUrl,
headers: { Authorization: `Bearer \${input:${tokenInputId}}` }
};

const mergeInputs = (inputs: any): any[] => {
const list = Array.isArray(inputs) ? inputs : [];
const index = list.findIndex((i) => i && i.id === tokenInputId);
if (index === -1) {
list.push(tokenInput);
} else {
list[index] = tokenInput;
}
return list;
};

if (fs.existsSync(mcpConfigPath)) {
try {
const existing = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf-8'));
const serverEntry = existing?.servers?.edk2code;
if (serverEntry && serverEntry.url === expectedUrl) {
void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'edk2code MCP already configured correctly.' });
return;
}
// Add or update the edk2code entry
if (!existing.servers) {
existing.servers = {};
}
existing.servers.edk2code = { type: "sse", url: expectedUrl };
existing.inputs = mergeInputs(existing.inputs);
existing.servers.edk2code = serverEntryValue;
fs.writeFileSync(mcpConfigPath, JSON.stringify(existing, null, 4), 'utf-8');
void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Updated edk2code entry in .vscode/mcp.json' });
await this.copyMcpTokenToClipboard();
void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Updated edk2code entry in .vscode/mcp.json. Access token copied to clipboard, paste it when VS Code asks for it.' });
} catch {
// File exists but is not valid JSON, overwrite
const mcpConfig = { servers: { edk2code: { type: "sse", url: expectedUrl } } };
const mcpConfig = { inputs: [tokenInput], servers: { edk2code: serverEntryValue } };
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 4), 'utf-8');
void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Replaced invalid .vscode/mcp.json' });
await this.copyMcpTokenToClipboard();
void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Replaced invalid .vscode/mcp.json. Access token copied to clipboard, paste it when VS Code asks for it.' });
}
return;
}

const mcpConfig = {
inputs: [tokenInput],
servers: {
"edk2code": {
"type": "sse",
"url": expectedUrl
}
"edk2code": serverEntryValue
}
};

if (!fs.existsSync(vscodePath)) {
fs.mkdirSync(vscodePath, { recursive: true });
}
fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 4), 'utf-8');
void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Created .vscode/mcp.json' });
await this.copyMcpTokenToClipboard();
void this._panel.webview.postMessage({ command: 'mcpConfigResult', message: 'Created .vscode/mcp.json. Access token copied to clipboard, paste it when VS Code asks for it.' });
}

private async copyMcpTokenToClipboard(): Promise<void> {
const token = await getOrCreateMcpToken();
await vscode.env.clipboard.writeText(token);
}


Expand Down