From 6d5fb11350c3b66f9bff3aeddcd877c358ddc708 Mon Sep 17 00:00:00 2001 From: Natalie Date: Mon, 29 Jun 2026 14:35:15 -0400 Subject: [PATCH] feat(lilith-packages): add mcp-common package Co-Authored-By: Claude Opus 4.8 --- mcp-common/dist/index.js | 126 +++++++++++++++++++++++++++ mcp-common/package.json | 28 ++++++ mcp-common/src/index.ts | 178 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 mcp-common/dist/index.js create mode 100644 mcp-common/package.json create mode 100644 mcp-common/src/index.ts diff --git a/mcp-common/dist/index.js b/mcp-common/dist/index.js new file mode 100644 index 0000000..0183ed8 --- /dev/null +++ b/mcp-common/dist/index.js @@ -0,0 +1,126 @@ +// src/index.ts +import { randomUUID } from "node:crypto"; +import { + createServer as createHttpServer +} from "node:http"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +var defaultLogger = (serverName) => ({ + info: (...a) => process.stderr.write(`[${serverName}] ${a.join(" ")} +`), + warn: (...a) => process.stderr.write(`[${serverName}] WARN ${a.join(" ")} +`), + error: (...a) => process.stderr.write(`[${serverName}] ERROR ${a.join(" ")} +`) +}); +function jsonError(res, status, code, message) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null })); +} +function readJsonBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw) + return resolve(undefined); + try { + resolve(JSON.parse(raw)); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} +async function runMcpServer(options) { + const { serverName, createServer, onShutdown } = options; + const log = options.logger ?? defaultLogger(serverName); + const port = Number(process.env["MCP_HTTP_PORT"]); + if (!Number.isInteger(port) || port <= 0) { + throw new Error("MCP_HTTP_PORT must be a positive integer"); + } + const authToken = process.env["MCP_AUTH_TOKEN"]; + if (!authToken) { + throw new Error("MCP_AUTH_TOKEN must be set (clients send `Authorization: Bearer `)"); + } + const transports = new Map; + const httpServer = createHttpServer((req, res) => { + handle(req, res).catch((err) => { + log.error("request failed", String(err)); + if (!res.headersSent) + jsonError(res, 500, -32603, "Internal error"); + else + res.end(); + }); + }); + async function handle(req, res) { + const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`); + if (url.pathname === "/healthz") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ ok: true, server: serverName })); + return; + } + if (url.pathname !== "/mcp") { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("Not Found"); + return; + } + if (req.headers["authorization"] !== `Bearer ${authToken}`) { + jsonError(res, 401, -32001, "Unauthorized"); + return; + } + const sessionId = req.headers["mcp-session-id"]; + const body = req.method === "POST" ? await readJsonBody(req) : undefined; + let transport = sessionId ? transports.get(sessionId) : undefined; + if (!transport) { + if (req.method !== "POST" || !isInitializeRequest(body)) { + jsonError(res, 400, -32000, "Bad Request: no valid session — send initialize first"); + return; + } + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid) => { + transports.set(sid, transport); + log.info?.(`session ${sid} initialized`); + } + }); + transport.onclose = () => { + const sid = transport?.sessionId; + if (sid && transports.delete(sid)) + log.info?.(`session ${sid} closed`); + }; + const server = createServer(); + await server.connect(transport); + } + await transport.handleRequest(req, res, body); + } + await new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(port, () => { + httpServer.off("error", reject); + log.info?.(`listening on :${port} (POST /mcp, GET /healthz)`); + resolve(); + }); + }); + let stopping = false; + const stop = (signal) => { + if (stopping) + return; + stopping = true; + log.info?.(`${signal} received, stopping`); + httpServer.close(); + for (const t of transports.values()) { + try { + t.close(); + } catch {} + } + Promise.resolve().then(() => onShutdown?.()).catch((err) => log.error("onShutdown cleanup failed", String(err))).finally(() => process.exit(0)); + }; + process.on("SIGTERM", () => stop("SIGTERM")); + process.on("SIGINT", () => stop("SIGINT")); +} +export { + runMcpServer +}; diff --git a/mcp-common/package.json b/mcp-common/package.json new file mode 100644 index 0000000..9682c40 --- /dev/null +++ b/mcp-common/package.json @@ -0,0 +1,28 @@ +{ + "name": "@lilith/mcp-common", + "version": "1.1.0", + "description": "Shared Streamable-HTTP harness (runMcpServer) for the quinn.* MCP servers — wraps an @modelcontextprotocol/sdk Server in a Bearer-authenticated HTTP listener with /healthz.", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "bun build --target=node --external '@modelcontextprotocol/sdk' src/index.ts --outfile=dist/index.js" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.26.0" + }, + "publishConfig": { + "registry": "http://134.199.243.61:4873/" + }, + "license": "UNLICENSED" +} diff --git a/mcp-common/src/index.ts b/mcp-common/src/index.ts new file mode 100644 index 0000000..fbe83e5 --- /dev/null +++ b/mcp-common/src/index.ts @@ -0,0 +1,178 @@ +/** + * @lilith/mcp-common — shared Streamable-HTTP harness for the quinn.* MCP servers. + * + * Each MCP server defines its tools in a `createServer()` factory that returns an + * `@modelcontextprotocol/sdk` `Server`, then hands it to `runMcpServer(...)`. This + * module turns that into a long-running HTTP service: + * - Streamable-HTTP MCP at POST/GET/DELETE /mcp (session-managed) + * - Bearer auth on every /mcp request via MCP_AUTH_TOKEN + * - liveness at GET /healthz + * - listens on MCP_HTTP_PORT + * + * Clients connect with { "type": "http", "url": ".../mcp", "headers": { "Authorization": "Bearer " } }. + */ + +import { randomUUID } from 'node:crypto'; +import { + createServer as createHttpServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http'; + +import type { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; + +export interface McpLogger { + info?: (...args: unknown[]) => void; + warn?: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +export interface RunMcpServerOptions { + /** Display name, used in logs and the /healthz payload. */ + serverName: string; + /** Factory returning a fresh, fully-wired MCP Server (one per session). */ + createServer: () => Server; + /** Optional stderr logger; a stderr default is used when omitted. */ + logger?: McpLogger; + /** Optional cleanup invoked once on SIGTERM/SIGINT before exit (e.g. close DB pools). */ + onShutdown?: () => void | Promise; +} + +const defaultLogger = (serverName: string): McpLogger => ({ + info: (...a) => process.stderr.write(`[${serverName}] ${a.join(' ')}\n`), + warn: (...a) => process.stderr.write(`[${serverName}] WARN ${a.join(' ')}\n`), + error: (...a) => process.stderr.write(`[${serverName}] ERROR ${a.join(' ')}\n`), +}); + +function jsonError(res: ServerResponse, status: number, code: number, message: string): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null })); +} + +function readJsonBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return resolve(undefined); + try { + resolve(JSON.parse(raw)); + } catch (err) { + reject(err); + } + }); + req.on('error', reject); + }); +} + +export async function runMcpServer(options: RunMcpServerOptions): Promise { + const { serverName, createServer, onShutdown } = options; + const log = options.logger ?? defaultLogger(serverName); + + const port = Number(process.env['MCP_HTTP_PORT']); + if (!Number.isInteger(port) || port <= 0) { + throw new Error('MCP_HTTP_PORT must be a positive integer'); + } + const authToken = process.env['MCP_AUTH_TOKEN']; + if (!authToken) { + throw new Error('MCP_AUTH_TOKEN must be set (clients send `Authorization: Bearer `)'); + } + + // Active sessions keyed by the SDK-issued mcp-session-id. + const transports = new Map(); + + const httpServer = createHttpServer((req, res) => { + void handle(req, res).catch((err: unknown) => { + log.error('request failed', String(err)); + if (!res.headersSent) jsonError(res, 500, -32603, 'Internal error'); + else res.end(); + }); + }); + + async function handle(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`); + + if (url.pathname === '/healthz') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, server: serverName })); + return; + } + + if (url.pathname !== '/mcp') { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('Not Found'); + return; + } + + // Bearer auth on the MCP endpoint only. + if (req.headers['authorization'] !== `Bearer ${authToken}`) { + jsonError(res, 401, -32001, 'Unauthorized'); + return; + } + + const sessionId = req.headers['mcp-session-id'] as string | undefined; + const body = req.method === 'POST' ? await readJsonBody(req) : undefined; + + let transport = sessionId ? transports.get(sessionId) : undefined; + + if (!transport) { + // A new session is only created by an `initialize` POST. Anything else + // (incl. a bare GET) without a known session is a protocol error — clients + // see HTTP 400 and re-handshake. + if (req.method !== 'POST' || !isInitializeRequest(body)) { + jsonError(res, 400, -32000, 'Bad Request: no valid session — send initialize first'); + return; + } + + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sid: string) => { + transports.set(sid, transport as StreamableHTTPServerTransport); + log.info?.(`session ${sid} initialized`); + }, + }); + transport.onclose = () => { + const sid = transport?.sessionId; + if (sid && transports.delete(sid)) log.info?.(`session ${sid} closed`); + }; + + const server = createServer(); + await server.connect(transport); + } + + await transport.handleRequest(req, res, body); + } + + await new Promise((resolve, reject) => { + httpServer.once('error', reject); + httpServer.listen(port, () => { + httpServer.off('error', reject); + log.info?.(`listening on :${port} (POST /mcp, GET /healthz)`); + resolve(); + }); + }); + + let stopping = false; + const stop = (signal: string): void => { + if (stopping) return; + stopping = true; + log.info?.(`${signal} received, stopping`); + httpServer.close(); + for (const t of transports.values()) { + try { + void t.close(); + } catch { + /* best-effort */ + } + } + void Promise.resolve() + .then(() => onShutdown?.()) + .catch((err: unknown) => log.error('onShutdown cleanup failed', String(err))) + .finally(() => process.exit(0)); + }; + process.on('SIGTERM', () => stop('SIGTERM')); + process.on('SIGINT', () => stop('SIGINT')); +}