diff --git a/.codebase-context/memory.json b/.codebase-context/memory.json index 8d2a55e..03602ac 100644 --- a/.codebase-context/memory.json +++ b/.codebase-context/memory.json @@ -246,5 +246,86 @@ "reason": "Auto-extracted from git commit history", "date": "2026-02-15T13:04:10.000Z", "source": "git" + }, + { + "id": "0c5271f19670", + "type": "gotcha", + "category": "conventions", + "memory": "fix: prevent orphaned processes via stdin/ppid/onclose lifecycle guards (#77)", + "reason": "Auto-extracted from git commit history", + "date": "2026-03-19T22:09:36.000Z", + "source": "git" + }, + { + "id": "76f3e2ea45ac", + "type": "gotcha", + "category": "conventions", + "memory": "fix: make exclude patterns recursive to prevent index pollution (#76)", + "reason": "Auto-extracted from git commit history", + "date": "2026-03-18T22:06:13.000Z", + "source": "git" + }, + { + "id": "391c1d465544", + "type": "gotcha", + "category": "conventions", + "memory": "fix: route MCP requests per project root (#65)", + "reason": "Auto-extracted from git commit history", + "date": "2026-03-07T15:37:13.000Z", + "source": "git" + }, + { + "id": "47fcd92fbcce", + "type": "gotcha", + "category": "conventions", + "memory": "fix: restore npx installs for published package", + "reason": "Auto-extracted from git commit history", + "date": "2026-03-05T22:24:47.000Z", + "source": "git" + }, + { + "id": "6cdf7384165a", + "type": "gotcha", + "category": "conventions", + "memory": "fix(get-team-patterns): filter out legacy testing framework categories from patterns", + "reason": "Auto-extracted from git commit history", + "date": "2026-03-05T19:26:03.000Z", + "source": "git" + }, + { + "id": "53c234c99d00", + "type": "gotcha", + "category": "conventions", + "memory": "fix(git): run tests only on pre-push", + "reason": "Auto-extracted from git commit history", + "date": "2026-03-05T19:16:04.000Z", + "source": "git" + }, + { + "id": "ac6653e45758", + "type": "gotcha", + "category": "conventions", + "memory": "fix(git): tighten pre-push formatting enforcement", + "reason": "Auto-extracted from git commit history", + "date": "2026-03-05T18:47:36.000Z", + "source": "git" + }, + { + "id": "cc519240e73c", + "type": "gotcha", + "category": "conventions", + "memory": "fix(cli): remove unused MetadataDependency import", + "reason": "Auto-extracted from git commit history", + "date": "2026-03-05T18:23:48.000Z", + "source": "git" + }, + { + "id": "9f949cc7137e", + "type": "gotcha", + "category": "conventions", + "memory": "fix(cli): formatter audit — render missing metadata fields, README callers qualifier", + "reason": "Auto-extracted from git commit history", + "date": "2026-03-05T18:17:37.000Z", + "source": "git" } ] \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9561c88..9d285b9 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ nul .planning-archive/* .codex/ grammars/*.wasm - +.agents/ .tmp-research-repos/ docs/visuals.md .repolore/ diff --git a/src/index.ts b/src/index.ts index 390fbca..e3dd141 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { createServer } from './server/factory.js'; +import { startHttpServer } from './server/http.js'; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -66,11 +68,28 @@ import { analyzerRegistry.register(new AngularAnalyzer()); analyzerRegistry.register(new GenericAnalyzer()); +// Flags that are NOT project paths — skip them when resolving the bootstrap root. +const KNOWN_FLAGS = new Set(['--http', '--port', '--help']); + // Resolve optional bootstrap root with validation handled later in main(). function resolveRootPath(): string | undefined { - const arg = process.argv[2]; const envPath = process.env.CODEBASE_ROOT; + // Walk argv starting at position 2, skip known flags and their values. + let arg: string | undefined; + for (let i = 2; i < process.argv.length; i++) { + const token = process.argv[i]; + if (!token) continue; + if (KNOWN_FLAGS.has(token)) { + if (token === '--port') i++; // skip the value that follows --port + continue; + } + if (!token.startsWith('-')) { + arg = token; + break; + } + } + // Priority: CLI arg > env var. Do not fall back to cwd in MCP mode. const configuredRoot = arg || envPath; if (!configuredRoot) { @@ -784,22 +803,173 @@ const PKG_VERSION: string = JSON.parse( await fs.readFile(new URL('../package.json', import.meta.url), 'utf-8') ).version; -const server: Server = new Server( - { - name: 'codebase-context', - version: PKG_VERSION - }, - { - capabilities: { - tools: {}, - resources: {} +/** + * Register all MCP request handlers on a Server instance. + * Exported so HTTP mode can wire up per-session servers with the + * same handler logic that closes over module-level state. + */ +export function registerHandlers(target: Server): void { + target.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: TOOLS }; + }); + + target.setRequestHandler(ListResourcesRequestSchema, async () => { + return { resources: buildResources() }; + }); + + target.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + const explicitProjectPath = getProjectPathFromContextResourceUri(uri); + + if (explicitProjectPath) { + const selection = await resolveProjectSelector(explicitProjectPath); + if (!selection.ok) { + throw new Error(`Unknown project resource: ${uri}`); + } + + const project = selection.project; + await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); + setActiveProject(project.rootPath); + return { + contents: [ + { + uri: buildProjectContextResourceUri(project.rootPath), + mimeType: 'text/plain', + text: await generateCodebaseContext(project) + } + ] + }; } - } -); -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { tools: TOOLS }; -}); + if (isContextResourceUri(uri)) { + const project = await resolveProjectForResource(); + return { + contents: [ + { + uri: CONTEXT_RESOURCE_URI, + mimeType: 'text/plain', + text: project ? await generateCodebaseContext(project) : buildProjectSelectionMessage() + } + ] + }; + } + + throw new Error(`Unknown resource: ${uri}`); + }); + + target.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const normalizedArgs = + args && typeof args === 'object' && !Array.isArray(args) + ? (args as Record) + : {}; + + try { + if (!toolNames.has(name)) { + return await dispatchTool(name, normalizedArgs, createWorkspaceToolContext()); + } + + const projectResolution = await resolveProjectForTool(normalizedArgs); + if (!projectResolution.ok) { + return projectResolution.response; + } + + const project = projectResolution.project; + + // Gate INDEX_CONSUMING tools on a valid, healthy index + let indexSignal: IndexSignal | undefined; + if ((INDEX_CONSUMING_TOOL_NAMES as readonly string[]).includes(name)) { + if (project.indexState.status === 'indexing') { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + status: 'indexing', + message: 'Index build in progress — please retry shortly' + }) + } + ] + }; + } + if (project.indexState.status === 'error') { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + status: 'error', + message: `Indexer error: ${project.indexState.error}` + }) + } + ] + }; + } + indexSignal = await ensureValidIndexOrAutoHeal(project); + if (indexSignal.action === 'rebuild-started') { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + status: 'indexing', + message: 'Index rebuild in progress — please retry shortly', + index: indexSignal + }) + } + ] + }; + } + } + + const result = await dispatchTool(name, normalizedArgs, createToolContext(project)); + + // Inject routing/index metadata into JSON responses so agents can reuse the resolved project safely. + if (indexSignal !== undefined && result.content?.[0]) { + try { + const parsed = JSON.parse(result.content[0].text); + result.content[0] = { + type: 'text', + text: JSON.stringify({ + ...parsed, + index: indexSignal, + project: buildProjectDescriptor(project.rootPath) + }) + }; + } catch { + /* response wasn't JSON, skip injection */ + } + } else if (result.content?.[0]) { + try { + const parsed = JSON.parse(result.content[0].text); + result.content[0] = { + type: 'text', + text: JSON.stringify({ ...parsed, project: buildProjectDescriptor(project.rootPath) }) + }; + } catch { + /* response wasn't JSON, skip injection */ + } + } + + return result; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Unexpected error: ${error instanceof Error ? error.message : String(error)}` + } + ], + isError: true + }; + } + }); +} + +const server: Server = createServer( + { name: 'codebase-context', version: PKG_VERSION }, + registerHandlers +); function buildResources(): Resource[] { const resources: Resource[] = [ @@ -824,10 +994,6 @@ function buildResources(): Resource[] { return resources; } -server.setRequestHandler(ListResourcesRequestSchema, async () => { - return { resources: buildResources() }; -}); - async function generateCodebaseContext(project: ProjectState): Promise { const intelligencePath = project.paths.intelligence; @@ -1023,46 +1189,6 @@ function buildProjectSelectionMessage(): string { return lines.join('\n'); } -server.setRequestHandler(ReadResourceRequestSchema, async (request) => { - const uri = request.params.uri; - const explicitProjectPath = getProjectPathFromContextResourceUri(uri); - - if (explicitProjectPath) { - const selection = await resolveProjectSelector(explicitProjectPath); - if (!selection.ok) { - throw new Error(`Unknown project resource: ${uri}`); - } - - const project = selection.project; - await initProject(project.rootPath, watcherDebounceMs, { enableWatcher: true }); - setActiveProject(project.rootPath); - return { - contents: [ - { - uri: buildProjectContextResourceUri(project.rootPath), - mimeType: 'text/plain', - text: await generateCodebaseContext(project) - } - ] - }; - } - - if (isContextResourceUri(uri)) { - const project = await resolveProjectForResource(); - return { - contents: [ - { - uri: CONTEXT_RESOURCE_URI, - mimeType: 'text/plain', - text: project ? await generateCodebaseContext(project) : buildProjectSelectionMessage() - } - ] - }; - } - - throw new Error(`Unknown resource: ${uri}`); -}); - /** * Extract memories from conventional git commits (refactor:, migrate:, fix:, revert:). * Scans last 90 days. Deduplicates via content hash. Zero friction alternative to manual memory. @@ -1344,114 +1470,6 @@ async function resolveProjectForResource(): Promise { return project; } -server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - const normalizedArgs = - args && typeof args === 'object' && !Array.isArray(args) - ? (args as Record) - : {}; - - try { - if (!toolNames.has(name)) { - return await dispatchTool(name, normalizedArgs, createWorkspaceToolContext()); - } - - const projectResolution = await resolveProjectForTool(normalizedArgs); - if (!projectResolution.ok) { - return projectResolution.response; - } - - const project = projectResolution.project; - - // Gate INDEX_CONSUMING tools on a valid, healthy index - let indexSignal: IndexSignal | undefined; - if ((INDEX_CONSUMING_TOOL_NAMES as readonly string[]).includes(name)) { - if (project.indexState.status === 'indexing') { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - status: 'indexing', - message: 'Index build in progress — please retry shortly' - }) - } - ] - }; - } - if (project.indexState.status === 'error') { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - status: 'error', - message: `Indexer error: ${project.indexState.error}` - }) - } - ] - }; - } - indexSignal = await ensureValidIndexOrAutoHeal(project); - if (indexSignal.action === 'rebuild-started') { - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - status: 'indexing', - message: 'Index rebuild in progress — please retry shortly', - index: indexSignal - }) - } - ] - }; - } - } - - const result = await dispatchTool(name, normalizedArgs, createToolContext(project)); - - // Inject routing/index metadata into JSON responses so agents can reuse the resolved project safely. - if (indexSignal !== undefined && result.content?.[0]) { - try { - const parsed = JSON.parse(result.content[0].text); - result.content[0] = { - type: 'text', - text: JSON.stringify({ - ...parsed, - index: indexSignal, - project: buildProjectDescriptor(project.rootPath) - }) - }; - } catch { - /* response wasn't JSON, skip injection */ - } - } else if (result.content?.[0]) { - try { - const parsed = JSON.parse(result.content[0].text); - result.content[0] = { - type: 'text', - text: JSON.stringify({ ...parsed, project: buildProjectDescriptor(project.rootPath) }) - }; - } catch { - /* response wasn't JSON, skip injection */ - } - } - - return result; - } catch (error) { - return { - content: [ - { - type: 'text', - text: `Unexpected error: ${error instanceof Error ? error.message : String(error)}` - } - ], - isError: true - }; - } -}); - /** * Initialize a project: migrate legacy structure, check index, start watcher. * Deduplicates via normalized root key. @@ -1685,9 +1703,76 @@ async function main() { } // Export server components for programmatic use -export { server, refreshKnownRootsFromClient, resolveRootPath, shouldReindex, TOOLS }; +export { server, refreshKnownRootsFromClient, resolveRootPath, shouldReindex, TOOLS, PKG_VERSION }; export { performIndexing }; +/** + * Start the server in HTTP mode. + * Each connecting MCP client gets its own Server+Transport pair, + * sharing the same module-level project state. + */ +async function startHttp(port: number): Promise { + // Validate bootstrap root the same way main() does + if (primaryRootPath) { + try { + const stats = await fs.stat(primaryRootPath); + if (!stats.isDirectory()) { + console.error(`ERROR: Root path is not a directory: ${primaryRootPath}`); + process.exit(1); + } + } catch { + console.error(`ERROR: Root path does not exist: ${primaryRootPath}`); + process.exit(1); + } + } + + const handle = await startHttpServer({ + name: 'codebase-context', + version: PKG_VERSION, + port, + registerHandlers, + onSessionReady: (sessionServer) => { + // Per-session roots change handler + sessionServer.setNotificationHandler(RootsListChangedNotificationSchema, async () => { + try { + await refreshKnownRootsFromClient(); + } catch { + /* best-effort */ + } + }); + } + }); + + // Register cleanup — no parent death guard or stdin listeners in HTTP mode + const stopAllWatchers = () => { + for (const project of getAllProjects()) { + project.stopWatcher?.(); + } + }; + + const shutdown = async () => { + console.error('[HTTP] Shutting down...'); + stopAllWatchers(); + await handle.close(); + process.exit(0); + }; + + process.once('SIGINT', () => void shutdown()); + process.once('SIGTERM', () => void shutdown()); + process.once('exit', stopAllWatchers); + + // If a bootstrap root was provided, auto-init it + if (primaryRootPath) { + registerKnownRoot(primaryRootPath); + await refreshDiscoveredProjectsForKnownRoots(); + const startupRoots = getKnownRootPaths(); + if (startupRoots.length === 1) { + await initProject(startupRoots[0], watcherDebounceMs, { enableWatcher: true }); + setActiveProject(startupRoots[0]); + } + } +} + // Only auto-start when run directly as CLI (not when imported as module) // Check if this module is the entry point const isDirectRun = @@ -1714,9 +1799,32 @@ if (isDirectRun) { process.exit(1); }); } else { - main().catch((error) => { - console.error('Fatal:', error); - process.exit(1); - }); + // Detect HTTP mode from flags or env vars + const httpFlag = process.argv.includes('--http') || process.env.CODEBASE_CONTEXT_HTTP === '1'; + + if (httpFlag) { + const portFlagIdx = process.argv.indexOf('--port'); + const portFromFlag = + portFlagIdx !== -1 ? Number.parseInt(process.argv[portFlagIdx + 1], 10) : undefined; + const portFromEnv = process.env.CODEBASE_CONTEXT_PORT + ? Number.parseInt(process.env.CODEBASE_CONTEXT_PORT, 10) + : undefined; + const port = + portFromFlag && Number.isFinite(portFromFlag) + ? portFromFlag + : portFromEnv && Number.isFinite(portFromEnv) + ? portFromEnv + : 3100; + + startHttp(port).catch((error) => { + console.error('Fatal:', error); + process.exit(1); + }); + } else { + main().catch((error) => { + console.error('Fatal:', error); + process.exit(1); + }); + } } } diff --git a/src/server/factory.ts b/src/server/factory.ts new file mode 100644 index 0000000..605fca6 --- /dev/null +++ b/src/server/factory.ts @@ -0,0 +1,42 @@ +/** + * Server factory for creating MCP Server instances. + * + * Decouples Server instantiation from handler registration so both + * stdio and HTTP transports can create fresh Server instances that + * share the same handler logic (which lives in index.ts and closes + * over module-level state). + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; + +export type ServerOptions = { + name: string; + version: string; +}; + +export type RegisterHandlers = (server: Server) => void; + +/** + * Create a new MCP Server instance with standard capabilities. + * Optionally registers handlers via the provided callback. + */ +export function createServer(options: ServerOptions, registerHandlers?: RegisterHandlers): Server { + const server = new Server( + { + name: options.name, + version: options.version + }, + { + capabilities: { + tools: {}, + resources: {} + } + } + ); + + if (registerHandlers) { + registerHandlers(server); + } + + return server; +} diff --git a/src/server/http.ts b/src/server/http.ts new file mode 100644 index 0000000..b5f469c --- /dev/null +++ b/src/server/http.ts @@ -0,0 +1,269 @@ +/** + * HTTP transport for the MCP server. + * + * Starts a Node.js HTTP server that routes requests to /mcp using + * StreamableHTTPServerTransport. Each client connection gets its own + * MCP Server + Transport pair while sharing the same module-level + * project state from index.ts. + */ + +import { + createServer as createHttpServer, + type IncomingMessage, + type ServerResponse +} from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { createServer } from './factory.js'; +import type { RegisterHandlers } from './factory.js'; + +/** Session inactivity timeout in milliseconds (30 minutes) */ +const SESSION_TIMEOUT_MS = 30 * 60 * 1000; + +/** How often to check for stale sessions (60 seconds) */ +const SESSION_CHECK_INTERVAL_MS = 60 * 1000; + +export type HttpServerOptions = { + /** Server name for MCP protocol */ + name: string; + /** Server version for MCP protocol */ + version: string; + /** Host to bind to (default: 127.0.0.1) */ + host?: string; + /** Port to listen on (default: 3100) */ + port?: number; + /** Handler registration callback — wires tool/resource handlers onto each session's Server */ + registerHandlers: RegisterHandlers; + /** + * Called after each per-session Server is created and connected. + * Use this to set up per-session notification handlers, roots refresh, etc. + */ + onSessionReady?: (server: Server) => void; +}; + +type SessionEntry = { + server: Server; + transport: StreamableHTTPServerTransport; + /** Last activity timestamp (ms since epoch) */ + lastActivity: number; +}; + +export type HttpServerHandle = { + /** Close all sessions and shut down the HTTP server */ + close: () => Promise; +}; + +export async function startHttpServer(options: HttpServerOptions): Promise { + const host = options.host ?? '127.0.0.1'; + const port = options.port ?? 3100; + const sessions = new Map(); + + function touchSession(sessionId: string): void { + const session = sessions.get(sessionId); + if (session) { + session.lastActivity = Date.now(); + } + } + + function createSessionServer(): { server: Server; transport: StreamableHTTPServerTransport } { + const server = createServer( + { name: options.name, version: options.version }, + options.registerHandlers + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID() + }); + + return { server, transport }; + } + + // Session timeout reaper — evicts sessions idle for > SESSION_TIMEOUT_MS. + const timeoutCheck = setInterval(() => { + const now = Date.now(); + for (const [sessionId, session] of sessions.entries()) { + if (now - session.lastActivity > SESSION_TIMEOUT_MS) { + console.error(`[HTTP] Session ${sessionId} timed out`); + void session.transport.close().catch(() => { + /* best effort */ + }); + sessions.delete(sessionId); + } + } + }, SESSION_CHECK_INTERVAL_MS); + timeoutCheck.unref(); + + const httpServer = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => { + try { + const url = new URL(req.url ?? '/', `http://${host}:${port}`); + + // Health check on root + if (url.pathname === '/' && req.method === 'GET') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', sessions: sessions.size })); + return; + } + + // Only handle /mcp + if (url.pathname !== '/mcp') { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + return; + } + + const method = req.method?.toUpperCase(); + + if (method === 'POST') { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (sessionId && sessions.has(sessionId)) { + // Existing session — delegate to its transport + touchSession(sessionId); + const session = sessions.get(sessionId)!; + await session.transport.handleRequest(req, res); + return; + } + + if (sessionId && !sessions.has(sessionId)) { + // Unknown session ID — 404 + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session not found' })); + return; + } + + // No session ID — new initialization request + const { server: mcpServer, transport } = createSessionServer(); + + // Connect server to transport + await mcpServer.connect(transport); + + // Register onclose before handleRequest so cleanup always fires. + transport.onclose = () => { + const sid = transport.sessionId; + if (sid && sessions.has(sid)) { + sessions.delete(sid); + console.error(`[HTTP] Session ${sid} disconnected`); + } + }; + + // Handle the init request — this generates the session ID + await transport.handleRequest(req, res); + + // After handleRequest, the session ID is available + const newSessionId = transport.sessionId; + if (newSessionId) { + sessions.set(newSessionId, { + server: mcpServer, + transport, + lastActivity: Date.now() + }); + console.error(`[HTTP] Session ${newSessionId} connected`); + + // Notify caller so they can set up per-session handlers (roots refresh, etc.) + options.onSessionReady?.(mcpServer); + } else { + // Malformed init request — SDK didn't assign a session ID; clean up the orphan. + void transport.close().catch(() => { + /* best effort */ + }); + } + + return; + } + + if (method === 'GET') { + // SSE streaming for server-initiated messages + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !sessions.has(sessionId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Missing or invalid session ID' })); + return; + } + + touchSession(sessionId); + const session = sessions.get(sessionId)!; + await session.transport.handleRequest(req, res); + return; + } + + if (method === 'DELETE') { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + if (!sessionId || !sessions.has(sessionId)) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session not found' })); + return; + } + + const session = sessions.get(sessionId)!; + try { + await session.transport.close(); + } finally { + sessions.delete(sessionId); + console.error(`[HTTP] Session ${sessionId} closed by client`); + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'session_closed' })); + return; + } + + // Method not allowed + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + } catch (err) { + console.error('[HTTP] Unhandled request error:', err); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } + } + }); + + // Handle server errors + httpServer.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + console.error( + `[HTTP] Port ${port} is already in use. Choose a different port with --port or CODEBASE_CONTEXT_PORT.` + ); + } else if (error.code === 'EACCES') { + console.error(`[HTTP] Permission denied for port ${port}. Try a port above 1024.`); + } else { + console.error(`[HTTP] Server error: ${error.message}`); + } + process.exit(1); + }); + + return new Promise((resolve) => { + httpServer.listen(port, host, () => { + console.error(`Codebase Context MCP server listening on http://${host}:${port}/mcp`); + + const handle: HttpServerHandle = { + close: async () => { + // Stop the timeout reaper + clearInterval(timeoutCheck); + + // Close all sessions + for (const [sessionId, session] of sessions.entries()) { + try { + await session.transport.close(); + console.error(`[HTTP] Session ${sessionId} closed (shutdown)`); + } catch { + // Best effort + } + sessions.delete(sessionId); + } + + // Shut down HTTP server + return new Promise((resolveClose, rejectClose) => { + httpServer.close((err) => { + if (err) rejectClose(err); + else resolveClose(); + }); + }); + } + }; + + resolve(handle); + }); + }); +}