126 lines
4.2 KiB
JavaScript
126 lines
4.2 KiB
JavaScript
// 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
|
|
};
|