Spaces:
Running
Running
| /** | |
| * 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")); | |