feat(lilith-packages): add mcp-common package
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
65aa4b1989
commit
6d5fb11350
3 changed files with 332 additions and 0 deletions
126
mcp-common/dist/index.js
vendored
Normal file
126
mcp-common/dist/index.js
vendored
Normal file
|
|
@ -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 <token>`)");
|
||||
}
|
||||
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
|
||||
};
|
||||
28
mcp-common/package.json
Normal file
28
mcp-common/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
178
mcp-common/src/index.ts
Normal file
178
mcp-common/src/index.ts
Normal file
|
|
@ -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 <token>" } }.
|
||||
*/
|
||||
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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<unknown> {
|
||||
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<void> {
|
||||
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 <token>`)');
|
||||
}
|
||||
|
||||
// Active sessions keyed by the SDK-issued mcp-session-id.
|
||||
const transports = new Map<string, StreamableHTTPServerTransport>();
|
||||
|
||||
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<void> {
|
||||
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<void>((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'));
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue