redscv95
Fix Claude.ai compatibility: explicit CORS + JSON response + eventStore
b52b304
Raw
History Blame Contribute Delete
9.23 kB
#!/usr/bin/env node
/**
* korean-privacy-law-mcp HTTP ์ง„์ž…์ .
*
* Stateful Streamable HTTP MCP โ€” initialize ์š”์ฒญ ์‹œ ์„ธ์…˜ ์ƒ์„ฑ, Mcp-Session-Id ํ—ค๋”๋กœ ํ›„์† ๋ผ์šฐํŒ….
* Hugging Face Spaces ยท fly.io ยท Cloud Run ๋“ฑ ์ปจํ…Œ์ด๋„ˆ ํ™˜๊ฒฝ ๋ฐฐํฌ์šฉ.
*
* API ํ‚ค(LAW_OC) ์ „๋‹ฌ ๋ฐฉ๋ฒ• (initialize ์š”์ฒญ ์‹œ 1ํšŒ โ€” ์œ„์—์„œ๋ถ€ํ„ฐ ์šฐ์„ ):
* 1. `?oc=KEY` ยท `?LAW_OC=KEY` ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ (Claude.ai ์ปค์Šคํ…€ ์ปค๋„ฅํ„ฐ URL ๊ธฐ๋ณธ ํŒจํ„ด)
* 2. `apikey` ยท `law-oc` ยท `x-law-oc` ยท `Authorization: Bearer KEY` ํ—ค๋”
* 3. `LAW_OC` ํ™˜๊ฒฝ๋ณ€์ˆ˜ (์„œ๋ฒ„ ์šด์˜์ž ๊ธฐ๋ณธ ํ‚ค, fallback)
*/
import { randomUUID } from "node:crypto";
import express from "express";
import type { Request, Response, NextFunction } from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js";
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { LawApiClient } from "./client/law-api-client.js";
import { loadEnv } from "./lib/env.js";
import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
import { ALL_TOOLS } from "./tools/registry.js";
loadEnv();
const PORT = Number(process.env.PORT ?? 7860);
const HOST = process.env.HOST ?? "0.0.0.0";
const DEFAULT_OC = process.env.LAW_OC;
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? "*";
const SESSION_MAX_IDLE_MS = 30 * 60 * 1000; // 30๋ถ„
interface SessionEntry {
server: Server;
transport: StreamableHTTPServerTransport;
lastAccess: number;
}
const sessions = new Map<string, SessionEntry>();
function logEvent(msg: string): void {
const ts = new Date().toISOString();
process.stderr.write(`[${ts}] [${SERVER_NAME}] ${msg}\n`);
}
function extractApiKey(req: Request): string | undefined {
// ํ—ค๋” ์šฐ์„  (๋‹ค์–‘ํ•œ ์ด๋ฆ„ ์ง€์›)
const headerCandidates = [
req.header("apikey"),
req.header("law-oc"),
req.header("law_oc"),
req.header("x-law-oc"),
req.header("x-api-key"),
req.header("authorization")?.replace(/^Bearer\s+/i, ""),
];
for (const candidate of headerCandidates) {
if (candidate && candidate.length > 0) return candidate;
}
// ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ
const queryCandidates = [req.query.oc, req.query.LAW_OC];
for (const candidate of queryCandidates) {
if (typeof candidate === "string" && candidate.length > 0) return candidate;
}
return DEFAULT_OC;
}
const app = express();
app.use(express.json({ limit: "4mb" }));
// CORS โ€” Claude.ai ๊ฐ™์€ ๋ธŒ๋ผ์šฐ์ € ํด๋ผ์ด์–ธํŠธ๊ฐ€ mcp-session-id ํ—ค๋”๋ฅผ ๋™๋ฐ˜ํ•œ
// ํ›„์† ์š”์ฒญ์— preflight(OPTIONS) ๋ฅผ ๋ณด๋‚ด๋ฏ€๋กœ ๋ช…์‹œ์  ํ—ˆ์šฉ ํ•„์š”.
app.use((req, res, next: NextFunction) => {
res.header("Access-Control-Allow-Origin", CORS_ORIGIN);
res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.header(
"Access-Control-Allow-Headers",
"Content-Type, Accept, Authorization, mcp-session-id, last-event-id, apikey, law-oc, x-law-oc, x-api-key"
);
res.header("Access-Control-Expose-Headers", "mcp-session-id");
res.header("Access-Control-Max-Age", "600");
res.header("X-Content-Type-Options", "nosniff");
if (req.method === "OPTIONS") {
res.sendStatus(200);
return;
}
next();
});
// idle ์„ธ์…˜ ์ž๋™ ์ •๋ฆฌ (5๋ถ„๋งˆ๋‹ค ์ฒดํฌ, 30๋ถ„ idle ์‹œ ์ข…๋ฃŒ)
setInterval(() => {
const now = Date.now();
for (const [sid, entry] of sessions) {
if (now - entry.lastAccess > SESSION_MAX_IDLE_MS) {
logEvent(`session ${sid.slice(0, 8)} idle timeout โ€” cleaning up`);
try {
void entry.transport.close();
void entry.server.close();
} catch {
/* ignore */
}
sessions.delete(sid);
}
}
}, 5 * 60 * 1000).unref();
app.get("/", (_req, res) => {
res.json({
name: SERVER_NAME,
version: SERVER_VERSION,
transport: "streamable-http",
mode: "stateful",
endpoint: "/mcp",
tools: ALL_TOOLS.length,
activeSessions: sessions.size,
docs: "https://github.com/scvcoder/korean-privacy-law-mcp",
apiKey: {
required: true,
methods: [
"?oc=YOUR_KEY (query)",
"apikey: YOUR_KEY (header)",
"x-law-oc: YOUR_KEY (header)",
],
issue: "https://open.law.go.kr/LSO/openApi/guideResult.do",
},
});
});
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
app.post("/mcp", async (req: Request, res: Response) => {
const sessionId = req.header("mcp-session-id");
try {
if (sessionId) {
const entry = sessions.get(sessionId);
if (entry) {
entry.lastAccess = Date.now();
await entry.transport.handleRequest(req, res, req.body);
return;
}
logEvent(`POST /mcp โ€” unknown session ${sessionId.slice(0, 8)}, returning 404 for re-init`);
res.status(404).json({
jsonrpc: "2.0",
error: { code: -32001, message: "Session not found. Please reinitialize." },
id: null,
});
return;
}
// ์ƒˆ ์„ธ์…˜ โ€” initialize ์š”์ฒญ๋งŒ ํ—ˆ์šฉ
if (!isInitializeRequest(req.body)) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided",
},
id: null,
});
return;
}
const apiKey = extractApiKey(req);
if (!apiKey) {
res.status(401).json({
jsonrpc: "2.0",
error: {
code: -32001,
message:
"๋ฒ•์ œ์ฒ˜ API ํ‚ค (LAW_OC) ๋ฏธ์ง€์ •. ?oc=YOUR_KEY ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ ๋˜๋Š” 'apikey' ํ—ค๋”๋กœ ์ „๋‹ฌํ•˜์„ธ์š”. " +
"๋ฐœ๊ธ‰: https://open.law.go.kr/LSO/openApi/guideResult.do",
},
id: null,
});
return;
}
const newId = randomUUID();
const client = new LawApiClient({ apiKey });
const server = createServer(client);
const eventStore = new InMemoryEventStore();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => newId,
enableJsonResponse: true,
eventStore,
onsessioninitialized: (id) => {
sessions.set(id, { server, transport, lastAccess: Date.now() });
logEvent(`session ${id.slice(0, 8)} initialized (active=${sessions.size})`);
},
});
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && sessions.has(sid)) {
sessions.delete(sid);
logEvent(`session ${sid.slice(0, 8)} closed (active=${sessions.size})`);
}
};
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
logEvent(`POST /mcp error: ${err instanceof Error ? err.message : String(err)}`);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
app.get("/mcp", async (req: Request, res: Response) => {
const sessionId = req.header("mcp-session-id");
const entry = sessionId ? sessions.get(sessionId) : undefined;
if (!entry) {
logEvent(`GET /mcp โ€” unknown session ${sessionId?.slice(0, 8) ?? "(none)"}`);
res.status(404).send("Session not found. Please reinitialize.");
return;
}
entry.lastAccess = Date.now();
try {
await entry.transport.handleRequest(req, res);
} catch (err) {
logEvent(`GET /mcp error: ${err instanceof Error ? err.message : String(err)}`);
if (!res.headersSent) res.status(500).send("Internal server error");
}
});
app.delete("/mcp", async (req: Request, res: Response) => {
const sessionId = req.header("mcp-session-id");
const entry = sessionId ? sessions.get(sessionId) : undefined;
if (!entry) {
res.status(404).send("Session not found");
return;
}
try {
await entry.transport.handleRequest(req, res);
sessions.delete(sessionId!);
logEvent(`session ${sessionId!.slice(0, 8)} terminated by client`);
} catch (err) {
logEvent(`DELETE /mcp error: ${err instanceof Error ? err.message : String(err)}`);
if (!res.headersSent) res.status(500).send("Error processing session termination");
}
});
const expressServer = app.listen(PORT, HOST, () => {
logEvent(
`v${SERVER_VERSION} HTTP MCP ์„œ๋ฒ„ ์‹œ์ž‘ โ€” ${HOST}:${PORT}/mcp (stateful, JSON-response, ๋„๊ตฌ ${ALL_TOOLS.length}๊ฐœ)`
);
if (!DEFAULT_OC) {
logEvent(
`LAW_OC ๋ฏธ์„ค์ • โ€” ์‚ฌ์šฉ์ž๊ฐ€ initialize ์‹œ ?oc=KEY ๋˜๋Š” apikey ํ—ค๋”๋กœ ์ „๋‹ฌํ•ด์•ผ ํ•จ.`
);
}
});
async function gracefulShutdown(signal: string): Promise<void> {
logEvent(`${signal} received, shutting down...`);
for (const [sid, entry] of sessions) {
try {
await entry.transport.close();
await entry.server.close();
} catch {
/* ignore */
}
sessions.delete(sid);
}
expressServer.close();
logEvent("server shutdown complete");
process.exit(0);
}
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));