GitNexus/gitnexus/src/server/mcp-http.ts
Nico Prieto 99c8b0aa0c fix(server): restore security guards and error handling
Address code review feedback on the server-mode PR:

Critical fixes:
- Restore CORS whitelist (localhost + gitnexus.vercel.app only)
- Bind to 127.0.0.1 by default; add --host CLI flag for opt-in remote access
- Restore path traversal guard on /api/file (resolve + startsWith check)
- Restore try/catch on all route handlers + global error middleware
- Restore SIGINT/SIGTERM graceful shutdown handlers

Bug fixes:
- Add mutex to core initKuzu to prevent race conditions on concurrent
  DB switches (two requests for different repos no longer corrupt state)
- Track ftsLoaded flag and reset on DB switch / close so FTS extension
  is reloaded for each new database connection
- Restore input validation on /api/query (cypher required) and
  /api/search (query required)

Improvements:
- Add TTL-based cleanup for orphaned MCP sessions (30min idle eviction)
  to prevent memory leaks from network drops that skip onclose

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 12:42:00 +01:00

87 lines
3.1 KiB
TypeScript

/**
* MCP over HTTP
*
* 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 KuzuDB per repo).
*
* Sessions are cleaned up on explicit close or after SESSION_TTL_MS of inactivity
* (guards against network drops that never trigger onclose).
*/
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 type { LocalBackend } from '../mcp/local/local-backend.js';
import { randomUUID } from 'crypto';
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): void {
const sessions = new Map<string, MCPSession>();
// 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();
}
app.all('/api/mcp', 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,
});
}
});
console.log('MCP HTTP endpoints mounted at /api/mcp');
}