diff --git a/gitnexus/src/cli/help-i18n.ts b/gitnexus/src/cli/help-i18n.ts index 0c898eccb7..9fb4bde391 100644 --- a/gitnexus/src/cli/help-i18n.ts +++ b/gitnexus/src/cli/help-i18n.ts @@ -70,6 +70,10 @@ const OPTION_DESCRIPTION_KEYS = { 'analyze|--embedding-device ': 'help.option.analyze.embeddingDevice', 'index|-f, --force': 'help.option.index.force', 'index|--allow-non-git': 'help.option.index.allowNonGit', + 'mcp|--http': 'help.option.mcp.http', + 'mcp|-p, --port ': 'help.option.port', + 'mcp|--host ': 'help.option.mcp.host', + 'mcp|--auth-token ': 'help.option.mcp.authToken', 'serve|-p, --port ': 'help.option.port', 'serve|--host ': 'help.option.serve.host', 'uninstall|-f, --force': 'help.option.uninstall.force', diff --git a/gitnexus/src/cli/i18n/en.ts b/gitnexus/src/cli/i18n/en.ts index f6a035adb7..9bbf3f4289 100644 --- a/gitnexus/src/cli/i18n/en.ts +++ b/gitnexus/src/cli/i18n/en.ts @@ -122,7 +122,8 @@ export const en = { 'help.command.index.description': 'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)', 'help.command.serve.description': 'Start local HTTP server for web UI connection', - 'help.command.mcp.description': 'Start MCP server (stdio) — serves all indexed repos', + 'help.command.mcp.description': + 'Start MCP server. Default: stdio. Use --http for a remote HTTP server (Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).', 'help.command.list.description': 'List all indexed repositories', 'help.command.status.description': 'Show index status for current repo', 'help.command.doctor.description': @@ -199,6 +200,11 @@ export const en = { 'help.option.index.allowNonGit': 'Allow registering folders that are not Git repositories', 'help.option.port': 'Port number', 'help.option.serve.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)', + 'help.option.mcp.http': 'Serve MCP over HTTP instead of stdio (for remote clients)', + 'help.option.mcp.host': + 'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.', + 'help.option.mcp.authToken': + 'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.', 'help.option.force.confirmation': 'Skip confirmation prompt', 'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)', 'help.option.clean.all': 'Clean all indexed repos', diff --git a/gitnexus/src/cli/i18n/zh-CN.ts b/gitnexus/src/cli/i18n/zh-CN.ts index 693d700aa0..15dcf22291 100644 --- a/gitnexus/src/cli/i18n/zh-CN.ts +++ b/gitnexus/src/cli/i18n/zh-CN.ts @@ -123,7 +123,8 @@ export const zhCN = { 'help.command.analyze.description': '索引仓库(完整分析)', 'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)', 'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器', - 'help.command.mcp.description': '启动 MCP 服务器(stdio)— 提供所有已索引仓库', + 'help.command.mcp.description': + '启动 MCP 服务器。默认为 stdio。使用 --http 启动远程 HTTP 服务器(Streamable HTTP: POST /mcp + 遗留 SSE: GET /sse, POST /messages)。', 'help.command.list.description': '列出所有已索引仓库', 'help.command.status.description': '显示当前仓库的索引状态', 'help.command.doctor.description': '显示运行平台能力和嵌入配置', @@ -187,6 +188,11 @@ export const zhCN = { 'help.option.index.allowNonGit': '允许注册非 Git 仓库文件夹', 'help.option.port': '端口号', 'help.option.serve.host': '绑定地址(默认:127.0.0.1;远程访问可用 0.0.0.0)', + 'help.option.mcp.http': '使用 HTTP 代替 stdio 提供 MCP 服务(适合远程客户端)', + 'help.option.mcp.host': + 'HTTP 绑定地址(仅与 --http 搭配使用)。默认:127.0.0.1(回环)。使用 0.0.0.0 向所有接口开放。', + 'help.option.mcp.authToken': + '要求 Authorization 头携带此 Bearer Token(仅与 --http 搭配使用);也可通过 GITNEXUS_MCP_AUTH_TOKEN 环境变量设置。非回环绑定(--host 0.0.0.0/::)时必填,否则拒绝启动。', 'help.option.force.confirmation': '跳过确认提示', 'help.option.uninstall.force': '应用更改(默认仅为预演预览)', 'help.option.clean.all': '清理所有已索引仓库', diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index eb5a5a8f64..f3f329ed29 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -142,7 +142,21 @@ program program .command('mcp') - .description('Start MCP server (stdio) — serves all indexed repos') + .description( + 'Start MCP server. Default: stdio. Use --http for a remote HTTP server ' + + '(Streamable HTTP at POST /mcp + legacy SSE at GET /sse, POST /messages).', + ) + .option('--http', 'Serve MCP over HTTP instead of stdio (for remote clients)') + .option('-p, --port ', 'HTTP port (only with --http). Default: 3000', '3000') + .option( + '--host ', + 'HTTP bind address (only with --http). Default: 127.0.0.1 (loopback). Use 0.0.0.0 to expose to all interfaces.', + '127.0.0.1', + ) + .option( + '--auth-token ', + 'Require this bearer token in the Authorization header (only with --http); may also be set via the GITNEXUS_MCP_AUTH_TOKEN env var. Required for a non-loopback bind (--host 0.0.0.0/::), which otherwise refuses to start.', + ) .action(createLbugLazyAction(() => import('./mcp.js'), 'mcpCommand')); program diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index 056b12591f..8b191db1d4 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -29,7 +29,12 @@ import { installGlobalStdoutSentinel } from '../mcp/stdio-context.js'; -export const mcpCommand = async () => { +export const mcpCommand = async (options?: { + http?: boolean; + port?: string; + host?: string; + authToken?: string; +}) => { // Install the global stdout sentinel as the very first thing — before // ANY other module loads. The static-import closure above is leaf-only // (stdio-context → stdio-capture, zero non-`node:` deps), so this is @@ -80,6 +85,37 @@ export const mcpCommand = async () => { ); } + // Start HTTP server or fall back to stdio (default). + if (options?.http) { + // Dynamically import the HTTP transport module AFTER the sentinel installs. + // http-transport.ts pulls in express/cors/MCP SDK HTTP transport; these must + // not load before installGlobalStdoutSentinel() runs (see module doc above). + const port = Number(options.port ?? 3000); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + logger.error( + { port: options.port }, + `Invalid --port value: "${options.port ?? ''}". Must be an integer between 1 and 65535.`, + ); + process.exit(1); + } + // Dynamic import keeps express/cors out of mcp.ts's static graph (stdio sentinel). + const { startMcpHttpServer, resolveAuthToken } = await import('../mcp/http-transport.js'); + try { + await startMcpHttpServer(backend, { + port, + host: options.host ?? '127.0.0.1', + authToken: resolveAuthToken(options.authToken, process.env), + }); + } catch (err) { + logger.error( + { err: err instanceof Error ? err.message : err }, + 'Failed to start the MCP HTTP server', + ); + process.exit(1); + } + return; + } + // Start MCP server (serves all repos, discovers new ones lazily) await startMCPServer(backend); }; diff --git a/gitnexus/src/mcp/http-transport.ts b/gitnexus/src/mcp/http-transport.ts new file mode 100644 index 0000000000..265ffa59ab --- /dev/null +++ b/gitnexus/src/mcp/http-transport.ts @@ -0,0 +1,606 @@ +/** + * Dedicated MCP HTTP server. + * + * Provides HTTP-based MCP transport supporting: + * - Modern Streamable HTTP: POST /mcp + * - Legacy SSE transport: GET /sse + POST /messages + * + * Started via `gitnexus mcp --http`. + * stdio remains the default mode for `gitnexus mcp` (no breaking change). + * + * Exports createStreamableHttpHandler and createSseHandlers so that + * server/mcp-http.ts (web-UI route mount) can reuse them without inverting + * the established server/ → mcp/ dependency direction. + * + * Security considerations: + * - Default binds to 127.0.0.1 (loopback only). + * - Use --auth-token to enable Bearer Token authentication. + * - Use --host 0.0.0.0 to expose to all interfaces (requires --auth-token — refuses to start otherwise). + * - CORS is restricted to loopback origins when no auth token is configured. + * - PNA (Private Network Access) header is emitted only in response to browser preflight requests. + */ + +import type { Server as HttpServer } from 'http'; +import { timingSafeEqual, randomUUID } from 'crypto'; +import express, { type Express, type Request, type Response, type NextFunction } from 'express'; +import cors from 'cors'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import { createMCPServer, installSignalShutdown } from './server.js'; +import type { LocalBackend } from './local/local-backend.js'; +import { logger } from '../core/logger.js'; + +/** HTTP server configuration options. */ +export interface McpHttpOptions { + /** Listening port. */ + port: number; + /** Bind address (default: 127.0.0.1). */ + host: string; + /** Bearer auth token (optional; no auth when omitted). */ + authToken?: string; +} + +interface MCPSession { + server: Server; + transport: StreamableHTTPServerTransport; + lastActivity: number; +} + +interface SSESession { + server: Server; + transport: SSEServerTransport; + lastActivity: number; +} + +/** Sessions idle longer than this are evicted. */ +const SESSION_TTL_MS = 30 * 60 * 1000; +/** Cleanup sweep runs every 5 minutes. */ +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; +/** Hard cap on concurrent sessions — guards against initialize-flood DoS. */ +const MAX_SESSIONS = 1000; + +/** + * Creates a Bearer Token authentication middleware. + * + * - When authToken is not set, all requests pass through. + * - When authToken is set, checks the Authorization: Bearer header. + * - Uses constant-time comparison to prevent timing oracle attacks. + * - Returns a JSON-RPC formatted 401 on failure. + */ +export function createAuthMiddleware(authToken?: string) { + return (req: Request, res: Response, next: NextFunction): void => { + if (!authToken) { + next(); + return; + } + + const header = req.headers['authorization']; + const expected = `Bearer ${authToken}`; + + // Constant-time comparison — prevents timing oracle on bearer token. + // Buffers must be the same byte-length for timingSafeEqual; mismatch means + // we create a same-length dummy so the comparison always runs in full. + let valid = false; + if (typeof header === 'string') { + const a = Buffer.from(header); + const b = Buffer.from(expected); + if (a.length === b.length) { + valid = timingSafeEqual(a, b); + } else { + // Different lengths — run dummy comparison to preserve constant time. + timingSafeEqual(Buffer.alloc(b.length), b); + } + } + + if (valid) { + next(); + return; + } + + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + id: null, + }); + }; +} + +/** + * Returns true when an Origin should be allowed by the no-auth (loopback-only) + * CORS policy — i.e. it is absent (non-browser caller) or a loopback origin. + * + * WHATWG URL keeps the brackets on IPv6 literals + * (`new URL('http://[::1]/').hostname === '[::1]'`) and canonicalizes the + * IPv4-mapped loopback to `[::ffff:7f00:1]`; loopback IPv4 is the whole + * 127.0.0.0/8 block — so all of those forms are matched explicitly. + */ +export function isLoopbackOrigin(origin: string | undefined): boolean { + if (!origin) return true; // no Origin → non-browser caller; CORS is not the control there + let hostname: string; + try { + ({ hostname } = new URL(origin)); + } catch { + return false; + } + return ( + hostname === 'localhost' || + hostname === '[::1]' || + hostname === '[::ffff:7f00:1]' || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) + ); +} + +/** True for the exact loopback bind addresses. */ +export function isLoopbackHost(host: string): boolean { + return host === '127.0.0.1' || host === 'localhost' || host === '::1'; +} + +/** True for any-interface wildcard binds, whose externally-used Host is unknowable. */ +export function isWildcardHost(host: string): boolean { + return host === '0.0.0.0' || host === '::'; +} + +/** + * Computes the SDK DNS-rebinding `allowedHosts` list (a Host-header allowlist) for a + * bind host/port, or `undefined` when protection should stay off. + * + * Wildcard binds (`0.0.0.0` / `::`) return `undefined` — the Host a client + * legitimately uses is unknowable, so the bearer token (required for non-loopback + * binds) is the control. Loopback binds allow all three loopback host forms + * (bare + `:port`); a specific host (e.g. `192.168.1.50`) allows that host + * (bare + `:port`), which is knowable and a free defence-in-depth win. + */ +export function computeAllowedHosts(host: string, port: number): string[] | undefined { + if (isWildcardHost(host)) return undefined; + const hosts = isLoopbackHost(host) ? ['127.0.0.1', 'localhost', '[::1]'] : [host]; + return hosts.flatMap((h) => [h, `${h}:${port}`]); +} + +/** + * Resolves the MCP HTTP bearer token from the `--auth-token` flag or the + * `GITNEXUS_MCP_AUTH_TOKEN` env var (the flag wins). An empty or whitespace-only + * value is treated as "no token" so a blank env var cannot silently disable auth + * (and slip past the non-loopback hard-fail). + */ +export function resolveAuthToken( + optToken: string | undefined, + env: NodeJS.ProcessEnv, +): string | undefined { + return (optToken ?? env.GITNEXUS_MCP_AUTH_TOKEN)?.trim() || undefined; +} + +/** Builds the SDK transport DNS-rebinding options from a bind host/port. */ +function dnsRebindingOptions( + host: string | undefined, + port: number | undefined, +): { enableDnsRebindingProtection?: boolean; allowedHosts?: string[] } { + if (host === undefined || port === undefined) return {}; + const allowedHosts = computeAllowedHosts(host, port); + return allowedHosts ? { enableDnsRebindingProtection: true, allowedHosts } : {}; +} + +/** + * Starts a periodic sweep that closes and evicts sessions idle longer than + * `ttlMs`, returning the (unref'd) timer. Shared by both transport factories to + * guard against network drops where the per-session onclose never fires. + */ +export function startIdleSweep( + sessions: Map, + ttlMs: number, + intervalMs: number, +): NodeJS.Timeout { + const timer = setInterval(() => { + const now = Date.now(); + for (const [id, session] of sessions) { + if (now - session.lastActivity > ttlMs) { + try { + session.server.close(); + } catch {} + sessions.delete(id); + } + } + }, intervalMs); + if (timer && typeof timer === 'object' && 'unref' in timer) { + (timer as NodeJS.Timeout).unref(); + } + return timer; +} + +/** + * Creates a reusable StreamableHTTP request handler. + * + * Encapsulates the session map and request-dispatch logic as an independent + * factory, reused by both startMcpHttpServer (POST /mcp) and the web-UI server + * route mount in server/mcp-http.ts (/api/mcp). + */ +export function createStreamableHttpHandler( + backend: LocalBackend, + opts: { createServer?: () => Server; host?: string; port?: number } = {}, +): { + handler: (req: Request, res: Response) => Promise; + cleanup: () => Promise; +} { + // Seam: tests inject createServer to observe the per-session Server lifecycle. + const createServer = opts.createServer ?? ((): Server => createMCPServer(backend)); + // DNS-rebinding protection (Host-header allowlist) when the bind host is known. + const dnsRebinding = dnsRebindingOptions(opts.host, opts.port); + const sessions = new Map(); + const cleanupTimer = startIdleSweep(sessions, SESSION_TTL_MS, CLEANUP_INTERVAL_MS); + + const handler = async (req: Request, res: Response): Promise => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (sessionId && sessions.has(sessionId)) { + // Existing session — delegate to its transport and refresh activity timestamp. + // `has` just returned true and the map is not mutated before `get`, so the + // lookup is non-null. + const session = sessions.get(sessionId)!; + session.lastActivity = Date.now(); + await session.transport.handleRequest(req, res, req.body); + } else if (sessionId) { + // Unknown / expired session ID — tell the client to re-initialize (per MCP spec). + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Session not found. Re-initialize.' }, + id: null, + }); + } else if (req.method === 'POST') { + // No session ID — new client. Only accept initialize requests to avoid + // orphaned Server instances that can never be reclaimed by the TTL sweep. + // Use the SDK's isInitializeRequest so a single-element JSON-RPC batch is + // recognised too, rather than a brittle `body.method === 'initialize'` check. + const body = req.body as unknown; + const messages = Array.isArray(body) ? body : [body]; + if (!messages.some(isInitializeRequest)) { + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: 'First request must be initialize. No session ID provided.', + }, + id: null, + }); + return; + } + + // Reject when the session cap is reached — prevents memory exhaustion via + // an initialize flood (each session holds a live Server + Transport). + if (sessions.size >= MAX_SESSIONS) { + res.status(503).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Server at session capacity. Try again later.' }, + id: null, + }); + return; + } + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + ...dnsRebinding, + }); + const server = createServer(); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + + if (transport.sessionId) { + sessions.set(transport.sessionId, { server, transport, lastActivity: Date.now() }); + const sid = transport.sessionId; + transport.onclose = () => { + sessions.delete(sid); + }; + } else { + // The SDK rejected this request (e.g. 406 on a missing/invalid Accept header, + // 415 on a bad Content-Type) before assigning a session id. The Server was + // already connected but will never be stored, so the TTL sweep and cleanup() + // can't reclaim it — close it now to avoid an orphaned-Server leak. + try { + await server.close(); + } catch {} + } + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'No valid session. Send a POST to initialize.' }, + id: null, + }); + } + }; + + const cleanup = async (): Promise => { + clearInterval(cleanupTimer); + const closers = [...sessions.values()].map(async (session) => { + try { + await Promise.resolve(session.server.close()); + } catch {} + }); + sessions.clear(); + await Promise.allSettled(closers); + }; + + return { handler, cleanup }; +} + +/** + * Creates legacy SSE transport handlers. + * + * GET /sse (or custom path) establishes the SSE stream; + * POST /messages (or custom path) receives client JSON-RPC messages. + * + * Includes the same idle-TTL eviction as createStreamableHttpHandler to prevent + * memory leaks when clients drop without closing the SSE connection cleanly. + * + * @param backend LocalBackend instance + * @param messagesPath Path clients POST messages to (default: '/messages') + */ +export function createSseHandlers( + backend: LocalBackend, + messagesPath = '/messages', + opts: { maxSessions?: number; host?: string; port?: number } = {}, +): { + sseHandler: (req: Request, res: Response) => Promise; + messageHandler: (req: Request, res: Response) => Promise; + cleanup: () => Promise; +} { + const maxSessions = opts.maxSessions ?? MAX_SESSIONS; + // DNS-rebinding protection (Host-header allowlist) when the bind host is known. + const dnsRebinding = dnsRebindingOptions(opts.host, opts.port); + const sseSessions = new Map(); + const cleanupTimer = startIdleSweep(sseSessions, SESSION_TTL_MS, CLEANUP_INTERVAL_MS); + + const sseHandler = async (req: Request, res: Response): Promise => { + // Cap concurrent SSE sessions — mirrors the streamable handler's MAX_SESSIONS + // guard so a flood of held-open GET /sse connections cannot allocate unbounded + // Server instances before the idle sweep reclaims them. + if (sseSessions.size >= maxSessions) { + res.status(503).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Server at session capacity. Try again later.' }, + id: null, + }); + return; + } + + // SSEServerTransport(endpoint, res, options): endpoint is the path clients POST to. + const transport = new SSEServerTransport(messagesPath, res, dnsRebinding); + const server = createMCPServer(backend); + + sseSessions.set(transport.sessionId, { server, transport, lastActivity: Date.now() }); + + transport.onclose = () => { + sseSessions.delete(transport.sessionId); + }; + + res.on('close', () => { + sseSessions.delete(transport.sessionId); + try { + server.close(); + } catch {} + }); + + // connect() calls transport.start(), which sends the SSE 'endpoint' event. + await server.connect(transport); + }; + + const messageHandler = async (req: Request, res: Response): Promise => { + const sessionId = + (req.query['sessionId'] as string | undefined) ?? + (req.headers['mcp-session-id'] as string | undefined); + const entry = sessionId ? sseSessions.get(sessionId) : undefined; + + if (!entry) { + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'SSE session not found. Reconnect to /sse.' }, + id: null, + }); + return; + } + + // Refresh activity timestamp so the TTL sweep does not evict an active session. + entry.lastActivity = Date.now(); + + // express.json() has already parsed the body — pass it as the third argument + // to avoid the SDK re-reading the already-consumed stream. + await entry.transport.handlePostMessage(req, res, req.body); + }; + + const cleanup = async (): Promise => { + clearInterval(cleanupTimer); + const closers = [...sseSessions.values()].map(async ({ server }) => { + try { + await Promise.resolve(server.close()); + } catch {} + }); + sseSessions.clear(); + await Promise.allSettled(closers); + }; + + return { sseHandler, messageHandler, cleanup }; +} + +/** + * Creates and starts the dedicated MCP HTTP server. + * + * Mounts the following routes: + * - GET /health — health check (no auth required; for orchestrators/probes) + * - POST /mcp — Streamable HTTP (modern clients) + * - GET /sse — legacy SSE stream (old clients) + * - POST /messages — legacy SSE message endpoint + * + * @param backend LocalBackend instance + * @param options Server configuration + * @returns The listening http.Server + */ +export async function startMcpHttpServer( + backend: LocalBackend, + options: McpHttpOptions, +): Promise { + const { port, host, authToken } = options; + + // Refuse to start an unauthenticated server on a non-loopback interface — that + // would silently expose every indexed repo to anyone who can reach the host. + // Loopback binds stay open by default; non-loopback binds require a token. + if (!authToken && !isLoopbackHost(host)) { + throw new Error( + `Refusing to start the MCP HTTP server on a non-loopback host (${host}) without ` + + 'authentication — it would expose all indexed repos to anyone who can reach it. ' + + 'Pass --auth-token (or set GITNEXUS_MCP_AUTH_TOKEN), or bind --host 127.0.0.1. ' + + 'This applies to --host 0.0.0.0 and --host :: as well.', + ); + } + + const app: Express = express(); + + // Suppress X-Powered-By to reduce information leakage. + app.disable('x-powered-by'); + + // PNA (Chrome 130+ Private Network Access) preflight support. + // The browser sends `Access-Control-Request-Private-Network: true` ONLY on the + // CORS preflight (an OPTIONS request); emit the matching allow header only then, + // never on actual GET/POST responses. Runs before cors() so the header survives + // onto the preflight response cors() short-circuits. + app.use((req: Request, res: Response, next: NextFunction) => { + if ( + req.method === 'OPTIONS' && + req.headers['access-control-request-private-network'] === 'true' + ) { + res.setHeader('Access-Control-Allow-Private-Network', 'true'); + } + next(); + }); + + // CORS policy: + // - With auth token: allow any origin (remote access is intentional and protected). + // - Without auth token: restrict to loopback origins only to prevent drive-by local exfiltration. + const corsOrigin = authToken + ? true + : (origin: string | undefined, cb: (err: Error | null, allow?: boolean) => void) => { + cb(null, isLoopbackOrigin(origin)); + }; + + app.use( + cors({ + origin: corsOrigin, + credentials: false, + allowedHeaders: ['Content-Type', 'Authorization', 'mcp-session-id', 'last-event-id'], + exposedHeaders: ['mcp-session-id'], + }), + ); + + const auth = createAuthMiddleware(authToken); + // Body parser applied per-route after auth, so unauthenticated requests never + // trigger the 10 MB parse. Malformed/oversized JSON from authenticated clients + // is converted to a JSON-RPC error envelope by the terminal error handler + // registered after the routes (see below). + const jsonBody = express.json({ limit: '10mb' }); + + // Health check — no auth required; safe to expose for probes and orchestrators. + app.get('/health', (_req: Request, res: Response) => { + res.json({ status: 'ok' }); + }); + + // Streamable HTTP (modern MCP clients) at POST /mcp. + const streamable = createStreamableHttpHandler(backend, { host, port }); + app.all('/mcp', auth, jsonBody, (req: Request, res: Response) => { + void streamable.handler(req, res).catch((err: unknown) => { + logger.error({ err }, 'MCP /mcp request failed'); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Internal MCP server error' }, + id: null, + }); + } + }); + }); + + // Legacy SSE: GET /sse opens the stream; POST /messages receives JSON-RPC messages. + const sse = createSseHandlers(backend, '/messages', { host, port }); + app.get('/sse', auth, (req: Request, res: Response) => { + void sse.sseHandler(req, res).catch((err: unknown) => { + logger.error({ err }, 'MCP /sse failed'); + }); + }); + app.post('/messages', auth, jsonBody, (req: Request, res: Response) => { + void sse.messageHandler(req, res).catch((err: unknown) => { + logger.error({ err }, 'MCP /messages failed'); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Internal error' }, + id: null, + }); + } + }); + }); + + // Terminal error handler: body-parser failures (malformed or oversized JSON) + // reach here via next(err). Without it, Express's default handler returns an + // HTML error page — leaking a stack trace and absolute install paths when + // NODE_ENV is unset (the default for a CLI) — instead of the JSON-RPC envelope + // every other path uses. + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + const e = (err ?? {}) as { type?: string; status?: number; statusCode?: number }; + const isBodyParseError = + e.type === 'entity.parse.failed' || + e.type === 'entity.too.large' || + err instanceof SyntaxError; + logger.error({ err }, 'MCP HTTP request error'); + if (res.headersSent) return; + if (isBodyParseError) { + res.status(e.status ?? e.statusCode ?? 400).json({ + jsonrpc: '2.0', + error: { code: -32700, message: 'Parse error' }, + id: null, + }); + return; + } + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Internal MCP server error' }, + id: null, + }); + }); + + return new Promise((resolve, reject) => { + const server = app.listen(port, host, () => { + const displayHost = host === '0.0.0.0' || host === '::' ? 'localhost' : host; + logger.info( + { port, host }, + `GitNexus MCP HTTP server listening on http://${displayHost}:${port} ` + + `(Streamable: POST /mcp · legacy SSE: GET /sse + POST /messages)`, + ); + resolve(server); + }); + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + logger.error( + { port, host }, + `Port ${port} is already in use. ` + + `Stop the conflicting process or use a different port: gitnexus mcp --http --port `, + ); + process.exit(1); + } + reject(err); + }); + + const shutdown = async (exitCode: number): Promise => { + server.close(); + await streamable.cleanup(); + await sse.cleanup(); + try { + await backend.disconnect(); + } catch {} + const { flushLoggerSync } = await import('../core/logger.js'); + flushLoggerSync(); + process.exit(exitCode); + }; + + // Use the shared signal wiring so SIGINT exits 130 and SIGTERM exits 143 + // (the repo's POSIX 128+signal convention), not a misleading exit(0). + installSignalShutdown((exitCode = 0) => void shutdown(exitCode)); + }); +} diff --git a/gitnexus/src/server/mcp-http.ts b/gitnexus/src/server/mcp-http.ts index 67ec947085..ccfd71adf6 100644 --- a/gitnexus/src/server/mcp-http.ts +++ b/gitnexus/src/server/mcp-http.ts @@ -1,93 +1,23 @@ /** - * MCP over HTTP + * MCP over HTTP — route mount helper for the web-UI server. * - * Mounts the GitNexus MCP server on Express using StreamableHTTP transport. - * Each connecting client gets its own stateful session; the LocalBackend - * is shared across all sessions (thread-safe — lazy LadybugDB per repo). + * Mounts the GitNexus MCP endpoint (/api/mcp) onto an existing Express + * application. Session management lives in mcp/http-transport.ts, preserving + * the established server/ → mcp/ dependency direction. * - * Sessions are cleaned up on explicit close or after SESSION_TTL_MS of inactivity - * (guards against network drops that never trigger onclose). + * Used by server/api.ts to wire up the full web server. */ import type { Express, Request, Response } from 'express'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; -import { createMCPServer } from '../mcp/server.js'; +import { createStreamableHttpHandler } from '../mcp/http-transport.js'; import type { LocalBackend } from '../mcp/local/local-backend.js'; -import { randomUUID } from 'crypto'; import { logger } from '../core/logger.js'; -interface MCPSession { - server: Server; - transport: StreamableHTTPServerTransport; - lastActivity: number; -} - -/** Idle sessions are evicted after 30 minutes */ -const SESSION_TTL_MS = 30 * 60 * 1000; -/** Cleanup sweep runs every 5 minutes */ -const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; - export function mountMCPEndpoints(app: Express, backend: LocalBackend): () => Promise { - const sessions = new Map(); - - // Periodic cleanup of idle sessions (guards against network drops) - const cleanupTimer = setInterval(() => { - const now = Date.now(); - for (const [id, session] of sessions) { - if (now - session.lastActivity > SESSION_TTL_MS) { - try { - session.server.close(); - } catch {} - sessions.delete(id); - } - } - }, CLEANUP_INTERVAL_MS); - if (cleanupTimer && typeof cleanupTimer === 'object' && 'unref' in cleanupTimer) { - (cleanupTimer as NodeJS.Timeout).unref(); - } - - const handleMcpRequest = async (req: Request, res: Response) => { - const sessionId = req.headers['mcp-session-id'] as string | undefined; - - if (sessionId && sessions.has(sessionId)) { - // Existing session — delegate to its transport - const session = sessions.get(sessionId)!; - session.lastActivity = Date.now(); - await session.transport.handleRequest(req, res, req.body); - } else if (sessionId) { - // Unknown/expired session ID — tell client to re-initialize (per MCP spec) - res.status(404).json({ - jsonrpc: '2.0', - error: { code: -32001, message: 'Session not found. Re-initialize.' }, - id: null, - }); - } else if (req.method === 'POST') { - // No session ID — new client initializing - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - }); - const server = createMCPServer(backend); - await server.connect(transport); - await transport.handleRequest(req, res, req.body); - - if (transport.sessionId) { - sessions.set(transport.sessionId, { server, transport, lastActivity: Date.now() }); - transport.onclose = () => { - sessions.delete(transport.sessionId!); - }; - } - } else { - res.status(400).json({ - jsonrpc: '2.0', - error: { code: -32000, message: 'No valid session. Send a POST to initialize.' }, - id: null, - }); - } - }; + const { handler, cleanup } = createStreamableHttpHandler(backend); app.all('/api/mcp', (req: Request, res: Response) => { - void handleMcpRequest(req, res).catch((err: any) => { + void handler(req, res).catch((err: unknown) => { logger.error({ err }, 'MCP HTTP request failed:'); if (res.headersSent) return; res.status(500).json({ @@ -98,17 +28,6 @@ export function mountMCPEndpoints(app: Express, backend: LocalBackend): () => Pr }); }); - const cleanup = async () => { - clearInterval(cleanupTimer); - const closers = [...sessions.values()].map(async (session) => { - try { - await Promise.resolve(session.server.close()); - } catch {} - }); - sessions.clear(); - await Promise.allSettled(closers); - }; - - console.log('MCP HTTP endpoints mounted at /api/mcp'); + logger.info('MCP HTTP endpoints mounted at /api/mcp'); return cleanup; } diff --git a/gitnexus/test/unit/mcp-http-transport.test.ts b/gitnexus/test/unit/mcp-http-transport.test.ts new file mode 100644 index 0000000000..105caff2ac --- /dev/null +++ b/gitnexus/test/unit/mcp-http-transport.test.ts @@ -0,0 +1,825 @@ +/** + * Unit Tests: MCP HTTP Transport + * + * Coverage: + * - createAuthMiddleware: no-auth / valid token / invalid token scenarios + * - startMcpHttpServer: port-0 smoke test (health endpoint, unauthenticated POST → 401) + * - createStreamableHttpHandler: new-session initialization, unknown session 404 + * - createSseHandlers: message routing, unknown sessionId 404 + * - mountMCPEndpoints refactor safety: still returns cleanup fn and registers /api/mcp + * + * Notes: + * - node_modules may not be installed; tests that exercise the MCP SDK rely on mocks. + * - HTTP server tests use port 0 (OS-assigned ephemeral port) bound to 127.0.0.1. + * - Each test closes the server and calls cleanup() to avoid handle leaks. + */ + +import http from 'http'; +import type { AddressInfo } from 'net'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import express from 'express'; +import type { Request, Response, NextFunction } from 'express'; +import { + createAuthMiddleware, + createStreamableHttpHandler, + createSseHandlers, + isLoopbackOrigin, + computeAllowedHosts, + resolveAuthToken, + startMcpHttpServer, + startIdleSweep, +} from '../../src/mcp/http-transport.js'; +import { + createMCPServer, + installSignalShutdown, + SHUTDOWN_EXIT_CODES, +} from '../../src/mcp/server.js'; +import { mountMCPEndpoints } from '../../src/server/mcp-http.js'; + +// ─── Live-HTTP helpers (real req/res for SDK-touching paths) ─────────── + +async function listen(app: express.Express): Promise<{ port: number; close: () => Promise }> { + const server = app.listen(0, '127.0.0.1'); + await new Promise((resolve) => server.once('listening', () => resolve())); + const port = (server.address() as AddressInfo).port; + return { + port, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +interface HttpResult { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +function request( + port: number, + method: string, + path: string, + headers: Record = {}, + body?: string, +): Promise { + return new Promise((resolve, reject) => { + const req = http.request({ hostname: '127.0.0.1', port, path, method, headers }, (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => (data += chunk.toString())); + res.on('end', () => + resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data }), + ); + }); + req.on('error', reject); + if (body !== undefined) req.write(body); + req.end(); + }); +} + +async function waitFor(predicate: () => boolean, timeoutMs = 500): Promise { + const start = Date.now(); + while (!predicate() && Date.now() - start < timeoutMs) { + await new Promise((r) => setTimeout(r, 10)); + } +} + +/** A schema-complete JSON-RPC initialize request (passes the SDK isInitializeRequest). */ +function validInitialize(id = 1): Record { + return { + jsonrpc: '2.0', + method: 'initialize', + id, + params: { + protocolVersion: '2025-03-26', + capabilities: {}, + clientInfo: { name: 'test', version: '1.0.0' }, + }, + }; +} + +// ─── Mock backend factory ────────────────────────────────────────────── + +function createMockBackend(overrides: Record = {}): unknown { + return { + callTool: vi.fn().mockResolvedValue({ result: 'ok' }), + listRepos: vi.fn().mockResolvedValue([]), + resolveRepo: vi + .fn() + .mockResolvedValue({ name: 'test', repoPath: '/tmp/test', lastCommit: 'abc' }), + getContext: vi.fn().mockReturnValue(null), + queryClusters: vi.fn().mockResolvedValue({ clusters: [] }), + queryProcesses: vi.fn().mockResolvedValue({ processes: [] }), + queryClusterDetail: vi.fn().mockResolvedValue({ error: 'not found' }), + queryProcessDetail: vi.fn().mockResolvedValue({ error: 'not found' }), + disconnect: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +// ─── Mock req/res factory ────────────────────────────────────────────── + +function createMockReq(headers: Record = {}): Request { + return { headers } as unknown as Request; +} + +function createMockRes(): Response & { _status: number; _body: unknown } { + const res = { + _status: 200, + _body: undefined, + headersSent: false, + status: vi.fn().mockImplementation(function (this: typeof res, code: number) { + this._status = code; + return this; + }), + json: vi.fn().mockImplementation(function (this: typeof res, body: unknown) { + this._body = body; + return this; + }), + }; + return res as unknown as Response & { _status: number; _body: unknown }; +} + +// ─── createAuthMiddleware ────────────────────────────────────────────── + +describe('createAuthMiddleware', () => { + it('calls next immediately when authToken is not set', () => { + const middleware = createAuthMiddleware(undefined); + const req = createMockReq(); + const res = createMockRes(); + const next = vi.fn() as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('calls next when the correct Bearer token is supplied', () => { + const middleware = createAuthMiddleware('my-secret-token'); + const req = createMockReq({ authorization: 'Bearer my-secret-token' }); + const res = createMockRes(); + const next = vi.fn() as NextFunction; + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('returns 401 when Authorization header is missing', () => { + const middleware = createAuthMiddleware('my-secret-token'); + const req = createMockReq(); // no headers + const res = createMockRes(); + const next = vi.fn() as NextFunction; + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(401); + expect(res._body).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized' }, + }); + }); + + it('returns 401 when the wrong token is supplied', () => { + const middleware = createAuthMiddleware('my-secret-token'); + const req = createMockReq({ authorization: 'Bearer wrong-token' }); + const res = createMockRes(); + const next = vi.fn() as NextFunction; + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(401); + }); + + it('returns 401 when Authorization header is missing the "Bearer " prefix', () => { + const middleware = createAuthMiddleware('my-secret-token'); + const req = createMockReq({ authorization: 'my-secret-token' }); + const res = createMockRes(); + const next = vi.fn() as NextFunction; + + middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res._status).toBe(401); + }); +}); + +// ─── startMcpHttpServer smoke tests ─────────────────────────────────── + +describe('startMcpHttpServer', () => { + const servers: Array<{ server: http.Server; cleanup: () => Promise }> = []; + + afterEach(async () => { + for (const { server, cleanup } of servers.splice(0)) { + await cleanup().catch(() => {}); + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + /** + * Starts the MCP HTTP server on an OS-assigned port (port 0), returns the + * bound port, a node http.Server handle, and the cleanup function. + */ + async function startOnFreePort(authToken?: string): Promise<{ + port: number; + server: http.Server; + cleanup: () => Promise; + }> { + const backend = createMockBackend(); + + // Wrap startMcpHttpServer to capture the returned http.Server. + const { startMcpHttpServer: start } = await import('../../src/mcp/http-transport.js'); + const resolvedServer = await start(backend as never, { + port: 0, + host: '127.0.0.1', + authToken, + }); + + const address = resolvedServer.address(); + const port = + address && typeof address === 'object' + ? address.port + : (() => { + throw new Error('no port'); + })(); + + const cleanup = async (): Promise => { + // afterEach closes the server handle. + }; + + return { port, server: resolvedServer, cleanup }; + } + + it('GET /health returns 200 { status: "ok" }', async () => { + const { port, server, cleanup } = await startOnFreePort(); + servers.push({ server, cleanup }); + + const body = await new Promise((resolve, reject) => { + http + .get(`http://127.0.0.1:${port}/health`, (res) => { + let data = ''; + res.on('data', (chunk: string) => (data += chunk)); + res.on('end', () => resolve(data)); + }) + .on('error', reject); + }); + + expect(JSON.parse(body)).toEqual({ status: 'ok' }); + }); + + it('POST /mcp without auth token returns 401 when --auth-token is configured', async () => { + const { port, server, cleanup } = await startOnFreePort('supersecret'); + servers.push({ server, cleanup }); + + const statusCode = await new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + path: '/mcp', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }, + (res) => { + res.resume(); // drain + resolve(res.statusCode ?? 0); + }, + ); + req.on('error', reject); + req.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialize', id: 1, params: {} })); + req.end(); + }); + + expect(statusCode).toBe(401); + }); + + it('U5: emits the PNA allow header only on an OPTIONS preflight carrying the request header', async () => { + const { port, server, cleanup } = await startOnFreePort(); // no auth → loopback CORS + servers.push({ server, cleanup }); + + const preflight = await request(port, 'OPTIONS', '/mcp', { + Origin: 'http://127.0.0.1:9999', + 'Access-Control-Request-Method': 'POST', + 'Access-Control-Request-Private-Network': 'true', + }); + expect(preflight.headers['access-control-allow-private-network']).toBe('true'); + + // A normal GET carrying the request header must NOT receive the allow header. + const get = await request(port, 'GET', '/health', { + 'Access-Control-Request-Private-Network': 'true', + }); + expect(get.headers['access-control-allow-private-network']).toBeUndefined(); + }); + + it('U8: refuses to start on a non-loopback host without a token', async () => { + const backend = createMockBackend(); + await expect( + startMcpHttpServer(backend as never, { host: '0.0.0.0', port: 0 }), + ).rejects.toThrow(/non-loopback/i); + await expect(startMcpHttpServer(backend as never, { host: '::', port: 0 })).rejects.toThrow( + /non-loopback/i, + ); + await expect( + startMcpHttpServer(backend as never, { host: '192.168.1.50', port: 0 }), + ).rejects.toThrow(); + }); + + it('U8: starts on a non-loopback host when a token is provided', async () => { + const backend = createMockBackend(); + const server = await startMcpHttpServer(backend as never, { + host: '0.0.0.0', + port: 0, + authToken: 'tok', + }); + servers.push({ server, cleanup: async () => {} }); + expect(server.listening).toBe(true); + }); + + it('U6: rejects a POST /mcp carrying a disallowed Host header (DNS-rebinding protection)', async () => { + const { port, server, cleanup } = await startOnFreePort(); // 127.0.0.1 → protection ON + servers.push({ server, cleanup }); + + const res = await request( + port, + 'POST', + '/mcp', + { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + Host: 'evil.example.com:1234', + }, + JSON.stringify(validInitialize()), + ); + + expect(res.status).toBe(403); + }); + + it('U3: malformed JSON from an authenticated client returns a JSON-RPC parse error (not HTML)', async () => { + const { port, server, cleanup } = await startOnFreePort('supersecret'); + servers.push({ server, cleanup }); + + const res = await request( + port, + 'POST', + '/mcp', + { 'Content-Type': 'application/json', Authorization: 'Bearer supersecret' }, + '{ this is not valid json ', + ); + + expect(res.status).toBe(400); + expect(String(res.headers['content-type'] ?? '')).toMatch(/application\/json/); + expect(JSON.parse(res.body)).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32700, message: 'Parse error' }, + id: null, + }); + }); +}); + +// ─── createStreamableHttpHandler ────────────────────────────────────── + +describe('createStreamableHttpHandler', () => { + it('attempts to create a new session for a POST with no session id', async () => { + const backend = createMockBackend(); + const { handler, cleanup } = createStreamableHttpHandler(backend as never); + + const req = { + headers: {}, + method: 'POST', + body: validInitialize(), + } as Request; + + const res = { + headersSent: false, + statusCode: 200, + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + setHeader: vi.fn(), + write: vi.fn(), + end: vi.fn(), + } as unknown as Response; + + // The handler calls StreamableHTTPServerTransport internally; without the real + // SDK installed the call may throw — that is acceptable in unit tests. + try { + await handler(req, res); + } catch { + // Expected when SDK is not installed. + } + + await cleanup(); + }); + + it('returns 400 when POST has no session id and body method is not initialize', async () => { + const backend = createMockBackend(); + const { handler, cleanup } = createStreamableHttpHandler(backend as never); + + const req = { + headers: {}, + method: 'POST', + body: { jsonrpc: '2.0', method: 'tools/list', id: 2, params: {} }, + } as Request; + + const res = createMockRes(); + + await handler(req, res); + + expect(res._status).toBe(400); + expect(res._body).toMatchObject({ jsonrpc: '2.0', error: { code: -32000 } }); + + await cleanup(); + }); + + it('returns 404 for an unknown session id', async () => { + const backend = createMockBackend(); + const { handler, cleanup } = createStreamableHttpHandler(backend as never); + + const req = { + headers: { 'mcp-session-id': 'non-existent-session-id' }, + method: 'GET', + body: undefined, + } as unknown as Request; + + const res = createMockRes(); + + await handler(req, res); + + expect(res._status).toBe(404); + expect(res._body).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Session not found. Re-initialize.' }, + }); + + await cleanup(); + }); + + it('returns 400 for a GET with no session id', async () => { + const backend = createMockBackend(); + const { handler, cleanup } = createStreamableHttpHandler(backend as never); + + const req = { + headers: {}, + method: 'GET', + body: undefined, + } as unknown as Request; + + const res = createMockRes(); + + await handler(req, res); + + expect(res._status).toBe(400); + expect(res._body).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32000, message: 'No valid session. Send a POST to initialize.' }, + }); + + await cleanup(); + }); + + it('U1: closes the orphaned Server when the SDK rejects an initialize before a session id', async () => { + const backend = createMockBackend(); + let closed = 0; + // Inject createServer so we can observe the per-session Server's close(). + const { handler, cleanup } = createStreamableHttpHandler(backend as never, { + createServer: () => { + const s = createMCPServer(backend as never); + const orig = s.close.bind(s); + s.close = (async () => { + closed += 1; + return orig(); + }) as typeof s.close; + return s; + }, + }); + + const app = express(); + app.use(express.json()); + app.all('/mcp', (req, res) => void handler(req, res).catch(() => {})); + const { port, close } = await listen(app); + + // POST initialize but with Accept: application/json ONLY (no text/event-stream): + // the SDK returns 406 BEFORE assigning transport.sessionId, exercising the orphan path. + const res = await request( + port, + 'POST', + '/mcp', + { 'Content-Type': 'application/json', Accept: 'application/json' }, + JSON.stringify(validInitialize()), + ); + + expect(res.status).toBe(406); + await waitFor(() => closed > 0); + expect(closed).toBeGreaterThan(0); // the connected Server was closed, not leaked + + await close(); + await cleanup(); + }); + + it('U10: treats a single-element JSON-RPC batch initialize as initialize (no 400)', async () => { + const backend = createMockBackend(); + const { handler, cleanup } = createStreamableHttpHandler(backend as never); + const req = { + headers: {}, + method: 'POST', + body: [validInitialize()], + } as unknown as Request; + const res = createMockRes(); + // Past the init gate, the SDK transport runs against the mock res and may throw; + // we only assert the gate did NOT short-circuit with a 400. + try { + await handler(req, res); + } catch { + /* SDK write on the mock res */ + } + expect(res._status).not.toBe(400); + await cleanup(); + }); + + it('U10: a non-initialize JSON-RPC batch still returns 400', async () => { + const backend = createMockBackend(); + const { handler, cleanup } = createStreamableHttpHandler(backend as never); + const req = { + headers: {}, + method: 'POST', + body: [{ jsonrpc: '2.0', method: 'tools/list', id: 2, params: {} }], + } as unknown as Request; + const res = createMockRes(); + await handler(req, res); + expect(res._status).toBe(400); + await cleanup(); + }); +}); + +// ─── createSseHandlers ──────────────────────────────────────────────── + +describe('createSseHandlers', () => { + it('returns 404 from messageHandler when sessionId is unknown', async () => { + const backend = createMockBackend(); + const { messageHandler, cleanup } = createSseHandlers(backend as never, '/messages'); + + const req = { + query: { sessionId: 'non-existent' }, + headers: {}, + body: {}, + } as unknown as Request; + + const res = createMockRes(); + + await messageHandler(req, res); + + expect(res._status).toBe(404); + expect(res._body).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32001, message: 'SSE session not found. Reconnect to /sse.' }, + }); + + await cleanup(); + }); + + it('returns 404 from messageHandler when no sessionId is provided', async () => { + const backend = createMockBackend(); + const { messageHandler, cleanup } = createSseHandlers(backend as never, '/messages'); + + const req = { + query: {}, + headers: {}, + body: {}, + } as unknown as Request; + + const res = createMockRes(); + + await messageHandler(req, res); + + expect(res._status).toBe(404); + + await cleanup(); + }); + + it('cleanup does not throw', async () => { + const backend = createMockBackend(); + const { cleanup } = createSseHandlers(backend as never, '/messages'); + + await expect(cleanup()).resolves.not.toThrow(); + }); + + it('U2: returns 503 (and allocates no Server) when the SSE session cap is reached', async () => { + const backend = createMockBackend(); + // maxSessions 0 → the cap is hit immediately, so the guard fires before any + // SSEServerTransport / Server is allocated. + const { sseHandler, messageHandler, cleanup } = createSseHandlers( + backend as never, + '/messages', + { maxSessions: 0 }, + ); + + const res = createMockRes(); + await sseHandler(createMockReq(), res); + + expect(res._status).toBe(503); + expect(res._body).toMatchObject({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Server at session capacity. Try again later.' }, + }); + + // No session was created — any message routes to the 404 path. + const msgRes = createMockRes(); + await messageHandler( + { query: { sessionId: 'anything' }, headers: {}, body: {} } as unknown as Request, + msgRes, + ); + expect(msgRes._status).toBe(404); + + await cleanup(); + }); +}); + +// ─── mountMCPEndpoints refactor safety ─────────────────────────────── + +describe('mountMCPEndpoints', () => { + it('returns a cleanup function', () => { + const backend = createMockBackend(); + const mockApp = { + all: vi.fn(), + }; + + const cleanup = mountMCPEndpoints(mockApp as never, backend as never); + + expect(typeof cleanup).toBe('function'); + }); + + it('registers the /api/mcp route', () => { + const backend = createMockBackend(); + const allCalls: Array<[string, ...unknown[]]> = []; + const mockApp = { + all: vi.fn().mockImplementation((path: string, ...args: unknown[]) => { + allCalls.push([path, ...args]); + }), + }; + + mountMCPEndpoints(mockApp as never, backend as never); + + const registeredPaths = allCalls.map(([path]) => path); + expect(registeredPaths).toContain('/api/mcp'); + }); + + it('cleanup function resolves without throwing', async () => { + const backend = createMockBackend(); + const mockApp = { + all: vi.fn(), + }; + + const cleanup = mountMCPEndpoints(mockApp as never, backend as never); + + await expect(cleanup()).resolves.not.toThrow(); + }); +}); + +// ─── McpHttpOptions type validation ────────────────────────────────── + +describe('McpHttpOptions type validation', () => { + it('createAuthMiddleware accepts undefined authToken', () => { + const middleware = createAuthMiddleware(); + expect(typeof middleware).toBe('function'); + }); + + it('createAuthMiddleware accepts a string authToken', () => { + const middleware = createAuthMiddleware('test-token'); + expect(typeof middleware).toBe('function'); + }); +}); + +// ─── isLoopbackOrigin (U4) ─────────────────────────────────────────── + +describe('isLoopbackOrigin', () => { + it('accepts loopback origins including IPv6 [::1], IPv4-mapped, and the 127/8 block', () => { + expect(isLoopbackOrigin('http://localhost:8080')).toBe(true); + expect(isLoopbackOrigin('http://127.0.0.1:5000')).toBe(true); + expect(isLoopbackOrigin('http://127.0.0.2:3000')).toBe(true); + expect(isLoopbackOrigin('http://[::1]:3000')).toBe(true); + expect(isLoopbackOrigin('http://[::ffff:127.0.0.1]:3000')).toBe(true); + }); + + it('treats a missing Origin as allowed (non-browser caller)', () => { + expect(isLoopbackOrigin(undefined)).toBe(true); + }); + + it('rejects non-loopback and look-alike origins', () => { + expect(isLoopbackOrigin('http://localhost.evil.com')).toBe(false); + expect(isLoopbackOrigin('http://127.0.0.1.evil.com')).toBe(false); + expect(isLoopbackOrigin('http://example.com')).toBe(false); + expect(isLoopbackOrigin('http://192.168.1.50:3000')).toBe(false); + expect(isLoopbackOrigin('null')).toBe(false); + expect(isLoopbackOrigin('not a url')).toBe(false); + }); +}); + +// ─── startIdleSweep (U12) ──────────────────────────────────────────── + +describe('startIdleSweep', () => { + it('closes and evicts sessions idle beyond the TTL, keeping fresh ones', () => { + vi.useFakeTimers(); + try { + const ttlMs = 30 * 60 * 1000; + const intervalMs = 5 * 60 * 1000; + const now = Date.now(); + const closed: string[] = []; + const make = (id: string, lastActivity: number) => ({ + server: { close: () => closed.push(id) } as unknown as ReturnType, + lastActivity, + }); + const map = new Map([ + ['stale', make('stale', now - 60 * 60 * 1000)], + ['fresh', make('fresh', now)], + ]); + + const timer = startIdleSweep(map, ttlMs, intervalMs); + vi.advanceTimersByTime(intervalMs + 1); + + expect(map.has('stale')).toBe(false); + expect(map.has('fresh')).toBe(true); + expect(closed).toEqual(['stale']); + + clearInterval(timer); + } finally { + vi.useRealTimers(); + } + }); +}); + +// ─── resolveAuthToken (U9) ─────────────────────────────────────────── + +describe('resolveAuthToken', () => { + it('uses the --auth-token flag when set, preferring it over the env var', () => { + expect(resolveAuthToken('flag', {})).toBe('flag'); + expect(resolveAuthToken('flag', { GITNEXUS_MCP_AUTH_TOKEN: 'env' })).toBe('flag'); + }); + + it('falls back to GITNEXUS_MCP_AUTH_TOKEN', () => { + expect(resolveAuthToken(undefined, { GITNEXUS_MCP_AUTH_TOKEN: 'env' })).toBe('env'); + }); + + it('treats empty/whitespace as no token (no silent auth bypass)', () => { + expect(resolveAuthToken('', {})).toBeUndefined(); + expect(resolveAuthToken(' ', {})).toBeUndefined(); + expect(resolveAuthToken(undefined, { GITNEXUS_MCP_AUTH_TOKEN: '' })).toBeUndefined(); + expect(resolveAuthToken(undefined, { GITNEXUS_MCP_AUTH_TOKEN: ' ' })).toBeUndefined(); + }); + + it('returns undefined when neither is set, and trims a real token', () => { + expect(resolveAuthToken(undefined, {})).toBeUndefined(); + expect(resolveAuthToken(' tok ', {})).toBe('tok'); + }); +}); + +// ─── shutdown signal wiring (U7) ───────────────────────────────────── + +describe('shutdown exit codes (U7)', () => { + it('wires SIGINT → 130 and SIGTERM → 143 via the shared installSignalShutdown', () => { + const handlers: Record void> = {}; + const exits: number[] = []; + + installSignalShutdown( + (code = 0) => { + exits.push(code); + }, + (event, listener) => { + handlers[event] = listener; + }, + ); + + handlers.SIGINT('SIGINT'); + handlers.SIGTERM('SIGTERM'); + + expect(SHUTDOWN_EXIT_CODES).toEqual({ SIGINT: 130, SIGTERM: 143 }); + expect(exits).toEqual([130, 143]); + }); +}); + +// ─── computeAllowedHosts (U6) ──────────────────────────────────────── + +describe('computeAllowedHosts', () => { + it('returns all loopback host forms (bare + :port) for a loopback bind', () => { + expect(computeAllowedHosts('127.0.0.1', 3000)).toEqual([ + '127.0.0.1', + '127.0.0.1:3000', + 'localhost', + 'localhost:3000', + '[::1]', + '[::1]:3000', + ]); + }); + + it('returns the specific host (bare + :port) for a non-loopback, non-wildcard bind', () => { + expect(computeAllowedHosts('192.168.1.50', 8080)).toEqual([ + '192.168.1.50', + '192.168.1.50:8080', + ]); + }); + + it('returns undefined (protection off) for wildcard binds', () => { + expect(computeAllowedHosts('0.0.0.0', 3000)).toBeUndefined(); + expect(computeAllowedHosts('::', 3000)).toBeUndefined(); + }); +});