diff --git a/.github/workflows/copilot-review.yml b/.github/workflows/copilot-review.yml index 2dd7283..db4a593 100644 --- a/.github/workflows/copilot-review.yml +++ b/.github/workflows/copilot-review.yml @@ -7,6 +7,7 @@ on: jobs: copilot-review: name: Fetch Standards & Request Copilot Review + if: false # sample workflow — disabled runs-on: self-hosted # Must have network access to your K8s cluster permissions: contents: read diff --git a/README.md b/README.md index 54787a5..82b424d 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ npm run lint # strict TypeScript check npm test # run tests npm run mcp:dev # start MCP stdio server (tsx, no build required) npm run mcp:start # start compiled MCP stdio server +npm run mcp:http:dev # start MCP Streamable HTTP server (tsx, no build required) +npm run mcp:http:start # start compiled MCP Streamable HTTP server npm run prisma:migrate # create/apply local migration npm run prisma:deploy # apply migrations in deployed environments npm run prisma:seed # load example standards @@ -116,6 +118,59 @@ Add the following to your MCP client configuration (for example, `claude_desktop | `standards://latest` | Latest active standards payload as JSON | | `standards://rule/{rule_key}` | A single standard by rule key | +## MCP Streamable HTTP Server + +The Streamable HTTP server exposes the same tools and resources as the stdio server over the [MCP Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). Remote MCP clients (such as Claude.ai) can connect to it over HTTPS via a reverse proxy. + +### Environment variables + +| Variable | Default | Description | +| --- | --- | --- | +| `MCP_API_KEY` | required | API key sent in `x-api-key` on every request. Unset = all requests rejected. | +| `MCP_HTTP_PORT` | `3001` | Port for the Streamable HTTP MCP server (avoids collision with Fastify on `3000`). | + +### Running locally + +```bash +# Development (no build step required) +DATABASE_URL=postgresql://user:password@localhost:5432/standards \ + MCP_API_KEY=secret \ + npm run mcp:http:dev + +# Production (build first) +npm run build +DATABASE_URL=postgresql://user:password@localhost:5432/standards \ + MCP_API_KEY=secret \ + npm run mcp:http:start +``` + +Requests without a valid `x-api-key` header return `401`. Deploy behind a TLS-terminating reverse proxy (nginx, Caddy, AWS ALB, etc.) before exposing to the public internet. + +### Client configuration + +Add the following to your MCP client configuration to connect to a remotely hosted instance: + +```json +{ + "mcpServers": { + "standards-api-remote": { + "type": "http", + "url": "https://your-host/mcp", + "headers": { + "x-api-key": "your-secret-key" + } + } + } +} +``` + +Replace `https://your-host/mcp` with the public URL of your reverse proxy. + +### Tools and resources + +The Streamable HTTP server exposes the same four tools and two resources as the stdio server. See the [MCP Server](#mcp-server) section above for the full list. + + ## Database The service creates one table: diff --git a/package.json b/package.json index ef32355..35eca21 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "start": "node dist/server.js", "mcp:dev": "tsx src/mcp/server.ts", "mcp:start": "node dist/src/mcp/server.js", + "mcp:http:dev": "tsx src/mcp/http-server.ts", + "mcp:http:start": "node dist/src/mcp/http-server.js", "test": "vitest run", "lint": "tsc -p tsconfig.json --noEmit", "prisma:generate": "prisma generate", diff --git a/src/mcp/http-server.ts b/src/mcp/http-server.ts new file mode 100644 index 0000000..8fa57d9 --- /dev/null +++ b/src/mcp/http-server.ts @@ -0,0 +1,308 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { PrismaClient } from "@prisma/client"; +import { PrismaStandardsRepository } from "../repositories/prisma-standards-repository.js"; +import { StandardsService } from "../services/standards-service.js"; +import { registerTools } from "./tools.js"; +import { registerResources } from "./resources.js"; + +export type Session = { server: McpServer; transport: StreamableHTTPServerTransport }; +export type SessionMap = Map; + +/** + * Returns true if the request carries a valid API key. + * Exported for unit testing. + */ +export function isAuthorized( + headerValue: string | string[] | undefined, + envKey: string | undefined +): boolean { + if (!envKey) return false; + const key = Array.isArray(headerValue) ? headerValue[0] : headerValue; + return key === envKey; +} + +function rejectUnauthorized(res: ServerResponse): void { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing or invalid API key" })); +} + +function rejectBadRequest(res: ServerResponse, message: string): void { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + // -32600 = Invalid Request per JSON-RPC 2.0 spec + error: { code: -32600, message }, + id: null + }) + ); +} + +function serverError(res: ServerResponse): void { + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32603, message: "Internal server error" }, + id: null + }) + ); + } else { + // Headers already sent (e.g. mid-SSE stream) — terminate the response so + // the connection doesn't hang. + res.end(); + } +} + +const MAX_BODY_BYTES = 1 * 1024 * 1024; // 1 MiB + +class BodyTooLargeError extends Error {} +class BadJsonError extends Error {} + +async function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let raw = ""; + let bytesRead = 0; + req.on("data", (chunk: Buffer) => { + bytesRead += chunk.byteLength; + if (bytesRead > MAX_BODY_BYTES) { + reject(new BodyTooLargeError("Request body exceeds maximum allowed size")); + req.destroy(); + return; + } + raw += chunk.toString(); + }); + req.on("end", () => { + if (raw.length === 0) { + resolve(undefined); + return; + } + try { + resolve(JSON.parse(raw) as unknown); + } catch { + reject(new BadJsonError("Request body is not valid JSON")); + } + }); + req.on("error", reject); + }); +} + +function buildMcpServer(service: StandardsService): McpServer { + const server = new McpServer({ name: "standards-api", version: "1.0.0" }); + registerTools(server, service); + registerResources(server, service); + return server; +} + +/** + * Creates a request handler for the MCP Streamable HTTP transport along with + * the session map it manages. Exported so tests can inject a memory-backed + * service and inspect sessions directly. + */ +export function createMcpHttpHandler(service: StandardsService): { + handler: (req: IncomingMessage, res: ServerResponse) => Promise; + sessions: SessionMap; +} { + const sessions: SessionMap = new Map(); + + async function handlePost(req: IncomingMessage, res: ServerResponse): Promise { + let body: unknown; + try { + body = await readBody(req); + } catch (err) { + if (err instanceof BodyTooLargeError) { + res.writeHead(413, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32600, message: "Request body too large" }, + id: null + }) + ); + } else if (err instanceof BadJsonError) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + // -32700 = Parse error per JSON-RPC 2.0 spec + error: { code: -32700, message: "Parse error: request body is not valid JSON" }, + id: null + }) + ); + } else { + console.error("Error reading request body:", err); + serverError(res); + } + return; + } + + if (body === undefined) { + rejectBadRequest(res, "POST body must not be empty"); + return; + } + + try { + const sessionId = req.headers["mcp-session-id"]; + const existingId = Array.isArray(sessionId) ? sessionId[0] : sessionId; + + if (existingId && sessions.has(existingId)) { + const { transport } = sessions.get(existingId)!; + await transport.handleRequest(req, res, body); + return; + } + + if (!existingId && isInitializeRequest(body)) { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => { + sessions.set(id, { server: mcpServer, transport }); + } + }); + + const mcpServer = buildMcpServer(service); + + transport.onclose = () => { + const id = transport.sessionId; + if (id) sessions.delete(id); + mcpServer.close().catch((err) => console.error("Error closing McpServer:", err)); + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, body); + return; + } + + rejectBadRequest(res, "No valid session ID provided"); + } catch (err) { + console.error("Error handling POST /mcp:", err); + serverError(res); + } + } + + async function handleGet(req: IncomingMessage, res: ServerResponse): Promise { + const sessionId = req.headers["mcp-session-id"]; + const id = Array.isArray(sessionId) ? sessionId[0] : sessionId; + + if (!id || !sessions.has(id)) { + rejectBadRequest(res, "Invalid or missing session ID"); + return; + } + + try { + const { transport } = sessions.get(id)!; + await transport.handleRequest(req, res); + } catch (err) { + console.error("Error handling GET /mcp:", err); + serverError(res); + } + } + + async function handleDelete(req: IncomingMessage, res: ServerResponse): Promise { + const sessionId = req.headers["mcp-session-id"]; + const id = Array.isArray(sessionId) ? sessionId[0] : sessionId; + + if (!id || !sessions.has(id)) { + rejectBadRequest(res, "Invalid or missing session ID"); + return; + } + + try { + const { transport } = sessions.get(id)!; + await transport.handleRequest(req, res); + } catch (err) { + console.error("Error handling DELETE /mcp:", err); + serverError(res); + } + } + + async function handler(req: IncomingMessage, res: ServerResponse): Promise { + let pathname: string; + try { + pathname = new URL(req.url ?? "", "http://localhost").pathname; + } catch { + rejectBadRequest(res, "Malformed request URL"); + return; + } + const method = req.method ?? ""; + + if (pathname !== "/mcp") { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Not found" })); + return; + } + + if (method === "POST") { + await handlePost(req, res); + } else if (method === "GET") { + await handleGet(req, res); + } else if (method === "DELETE") { + await handleDelete(req, res); + } else { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + } + } + + return { handler, sessions }; +} + +// Normalize argv[1] to an absolute path so the comparison works whether npm +// passes a relative path (e.g. "src/mcp/http-server.ts") or an absolute one. +const isMain = resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url); + +if (isMain) { + const MCP_HTTP_PORT = parseInt(process.env.MCP_HTTP_PORT ?? "3001", 10); + + const prisma = new PrismaClient(); + const repository = new PrismaStandardsRepository(prisma); + const service = new StandardsService(repository); + + const { handler, sessions } = createMcpHttpHandler(service); + + const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { + if (!isAuthorized(req.headers["x-api-key"], process.env.MCP_API_KEY)) { + rejectUnauthorized(res); + return; + } + await handler(req, res); + }); + + async function shutdown(): Promise { + // Close every active McpServer so SSE streams are terminated and clients + // receive a clean disconnect rather than a socket hang. + await Promise.allSettled( + [...sessions.values()].map(({ server }) => server.close()) + ); + sessions.clear(); + // Force-close any remaining open connections (e.g. lingering SSE streams) + // so httpServer.close() resolves promptly instead of hanging until idle. + httpServer.closeAllConnections(); + await new Promise((resolve, reject) => { + httpServer.close((err) => (err ? reject(err) : resolve())); + }); + await prisma.$disconnect(); + } + + function handleSignal(signal: string): void { + shutdown() + .then(() => process.exit(0)) + .catch((err) => { + console.error(`Shutdown error on ${signal}:`, err); + process.exit(1); + }); + } + + process.on("SIGINT", () => handleSignal("SIGINT")); + process.on("SIGTERM", () => handleSignal("SIGTERM")); + + httpServer.listen(MCP_HTTP_PORT, () => { + console.log(`MCP Streamable HTTP server listening on port ${MCP_HTTP_PORT}`); + }); +} diff --git a/test/mcp-http-server.test.ts b/test/mcp-http-server.test.ts new file mode 100644 index 0000000..34f04f2 --- /dev/null +++ b/test/mcp-http-server.test.ts @@ -0,0 +1,170 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; +import { isAuthorized, createMcpHttpHandler } from "../src/mcp/http-server.js"; +import { MemoryStandardsRepository } from "../src/repositories/memory-standards-repository.js"; +import { StandardsService } from "../src/services/standards-service.js"; +import { seedStandards } from "../src/seed-data.js"; + +describe("isAuthorized", () => { + it("returns false when MCP_API_KEY is undefined", () => { + expect(isAuthorized("any-key", undefined)).toBe(false); + }); + + it("returns false when MCP_API_KEY is empty string", () => { + expect(isAuthorized("any-key", "")).toBe(false); + }); + + it("returns false when header is missing", () => { + expect(isAuthorized(undefined, "secret")).toBe(false); + }); + + it("returns false when header does not match the env key", () => { + expect(isAuthorized("wrong-key", "secret")).toBe(false); + }); + + it("returns true when header matches the env key", () => { + expect(isAuthorized("secret", "secret")).toBe(true); + }); + + it("uses the first value when header is an array", () => { + expect(isAuthorized(["secret", "other"], "secret")).toBe(true); + expect(isAuthorized(["other", "secret"], "secret")).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Session lifecycle integration tests +// --------------------------------------------------------------------------- + +const INIT_BODY = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "test-client", version: "0.0.0" } + } +}; + +// Streamable HTTP requires both Content-Type and Accept headers on POST requests. +const MCP_POST_HEADERS = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream" +}; + +function makeTestServer() { + const repo = new MemoryStandardsRepository(seedStandards); + const service = new StandardsService(repo); + const { handler, sessions } = createMcpHttpHandler(service); + + const server = createServer(async (req, res) => { + await handler(req, res); + }); + + return new Promise<{ + base: string; + sessions: ReturnType["sessions"]; + close: () => Promise; + }>((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resolve({ + base: `http://127.0.0.1:${port}`, + sessions, + close: () => new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))) + }); + }); + }); +} + +describe("MCP HTTP session lifecycle", () => { + it("returns 400 for POST without session ID and non-initialize body", async () => { + const { base, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: MCP_POST_HEADERS, + body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", params: {}, id: 1 }) + }); + expect(res.status).toBe(400); + } finally { + await close(); + } + }); + + it("returns 400 for GET without a session ID", async () => { + const { base, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { method: "GET" }); + expect(res.status).toBe(400); + } finally { + await close(); + } + }); + + it("returns 400 for DELETE without a session ID", async () => { + const { base, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { method: "DELETE" }); + expect(res.status).toBe(400); + } finally { + await close(); + } + }); + + it("creates a session on initialize and stores it in the sessions map", async () => { + const { base, sessions, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: MCP_POST_HEADERS, + body: JSON.stringify(INIT_BODY) + }); + expect(res.status).toBe(200); + const sessionId = res.headers.get("mcp-session-id"); + expect(sessionId).toBeTruthy(); + expect(sessions.has(sessionId!)).toBe(true); + } finally { + await close(); + } + }); + + it("routes a subsequent POST with a valid session ID to the transport", async () => { + const { base, close } = await makeTestServer(); + try { + // Initialize to get a session + const initRes = await fetch(`${base}/mcp`, { + method: "POST", + headers: MCP_POST_HEADERS, + body: JSON.stringify(INIT_BODY) + }); + const sessionId = initRes.headers.get("mcp-session-id")!; + + // Send initialized notification (required by MCP before issuing requests) + const notifyRes = await fetch(`${base}/mcp`, { + method: "POST", + headers: { ...MCP_POST_HEADERS, "mcp-session-id": sessionId }, + body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }) + }); + // Notification is fire-and-forget — transport returns 202 + expect([200, 202]).toContain(notifyRes.status); + } finally { + await close(); + } + }); + + it("returns 400 for GET with an unknown session ID", async () => { + const { base, close } = await makeTestServer(); + try { + const res = await fetch(`${base}/mcp`, { + method: "GET", + headers: { "mcp-session-id": "nonexistent-session-id" } + }); + expect(res.status).toBe(400); + } finally { + await close(); + } + }); +});