korean-law-mcp 4.7.1 → 4.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Streamable HTTP 서버 - 리모트 배포용 (MCP 2025-03-26 스펙 준수)
3
+ */
4
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ export declare function startSSEServer(server: Server, port: number): Promise<void>;
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Streamable HTTP 서버 - 리모트 배포용 (MCP 2025-03-26 스펙 준수)
3
+ */
4
+ import express from "express";
5
+ import { randomUUID } from "node:crypto";
6
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+ import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js";
8
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
9
+ import { sessionStore, setSessionApiKey, deleteSession } from "../lib/session-state.js";
10
+ import { VERSION } from "../version.js";
11
+ export async function startSSEServer(server, port) {
12
+ const app = express();
13
+ const transports = {};
14
+ // JSON 파싱 미들웨어 (크기 제한 명시)
15
+ app.use(express.json({ limit: "100kb" }));
16
+ // 유휴 세션 정리 (30분)
17
+ const SESSION_IDLE_TIMEOUT = 30 * 60 * 1000;
18
+ const MAX_SESSIONS = 100;
19
+ setInterval(() => {
20
+ const now = Date.now();
21
+ for (const sid of Object.keys(transports)) {
22
+ const session = transports[sid];
23
+ if (session.lastAccess && now - session.lastAccess > SESSION_IDLE_TIMEOUT) {
24
+ try {
25
+ session.transport.close();
26
+ }
27
+ catch { /* ignore */ }
28
+ delete transports[sid];
29
+ deleteSession(sid);
30
+ }
31
+ }
32
+ }, 5 * 60 * 1000).unref();
33
+ // CORS 및 보안 헤더 설정
34
+ const corsOrigin = process.env.CORS_ORIGIN || "*";
35
+ app.use((req, res, next) => {
36
+ res.header("Access-Control-Allow-Origin", corsOrigin);
37
+ res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, HEAD");
38
+ res.header("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization, Mcp-Protocol-Version, mcp-protocol-version, Mcp-Session-Id, mcp-session-id, Last-Event-ID, last-event-id, Traceparent, Tracestate");
39
+ res.header("Access-Control-Expose-Headers", "Mcp-Session-Id, Content-Type, Mcp-Protocol-Version, Traceparent, Tracestate");
40
+ res.header("Access-Control-Max-Age", "86400");
41
+ // Security headers (http-server.ts와 동일)
42
+ res.header("X-Content-Type-Options", "nosniff");
43
+ res.header("X-Frame-Options", "DENY");
44
+ res.header("Referrer-Policy", "strict-origin-when-cross-origin");
45
+ res.header("Mcp-Protocol-Version", "2025-03-26");
46
+ if (req.method === "OPTIONS") {
47
+ return res.sendStatus(200);
48
+ }
49
+ next();
50
+ });
51
+ // 헬스체크 엔드포인트
52
+ app.get("/", (req, res) => {
53
+ res.json({
54
+ name: "Korean Law MCP Server",
55
+ version: VERSION,
56
+ status: "running",
57
+ protocol: "streamable-http",
58
+ endpoints: {
59
+ mcp: "/mcp",
60
+ health: "/health"
61
+ }
62
+ });
63
+ });
64
+ app.get("/health", (req, res) => {
65
+ res.json({ status: "ok", timestamp: new Date().toISOString() });
66
+ });
67
+ // MCP POST 엔드포인트 (초기화 및 요청 처리)
68
+ app.post("/mcp", async (req, res) => {
69
+ const sessionId = req.headers["mcp-session-id"];
70
+ // API 키 추출 (http-server.ts와 동일 패턴)
71
+ const apiKeyFromHeader = req.headers["apikey"] ||
72
+ req.headers["law_oc"] ||
73
+ req.headers["law-oc"] ||
74
+ req.headers["LAW_OC"] ||
75
+ req.headers["x-api-key"] ||
76
+ req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ||
77
+ req.headers["x-law-oc"];
78
+ if (sessionId) {
79
+ console.error(`Received MCP request for session: ${sessionId.slice(0, 8)}...`);
80
+ }
81
+ else {
82
+ console.error("New MCP request (no session ID)");
83
+ }
84
+ try {
85
+ let transport;
86
+ if (sessionId && transports[sessionId]) {
87
+ // 기존 세션 재사용 + 접근 시각 갱신
88
+ ;
89
+ transports[sessionId].lastAccess = Date.now();
90
+ transport = transports[sessionId].transport;
91
+ // API 키 업데이트 (헤더에서 제공된 경우)
92
+ if (apiKeyFromHeader) {
93
+ setSessionApiKey(sessionId, apiKeyFromHeader);
94
+ }
95
+ // AsyncLocalStorage로 세션 ID 격리 (동시 요청 안전)
96
+ await sessionStore.run(sessionId, async () => {
97
+ await transport.handleRequest(req, res, req.body);
98
+ });
99
+ return;
100
+ }
101
+ else if (!sessionId && isInitializeRequest(req.body)) {
102
+ // 새 세션 초기화
103
+ // 세션 수 제한 — transport 생성 전에 체크하여 리소스 누수 방지
104
+ if (Object.keys(transports).length >= MAX_SESSIONS) {
105
+ res.status(503).json({
106
+ jsonrpc: "2.0",
107
+ error: { code: -32000, message: `Max sessions (${MAX_SESSIONS}) reached. Try again later.` },
108
+ id: null,
109
+ });
110
+ return;
111
+ }
112
+ const eventStore = new InMemoryEventStore();
113
+ transport = new StreamableHTTPServerTransport({
114
+ sessionIdGenerator: () => randomUUID(),
115
+ eventStore,
116
+ onsessioninitialized: (newSessionId) => {
117
+ transports[newSessionId] = { transport, lastAccess: Date.now() };
118
+ if (apiKeyFromHeader) {
119
+ setSessionApiKey(newSessionId, apiKeyFromHeader);
120
+ }
121
+ }
122
+ });
123
+ // 세션 종료 시 정리
124
+ transport.onclose = () => {
125
+ const sid = transport.sessionId;
126
+ if (sid && transports[sid]) {
127
+ console.error(`Transport closed for session ${sid.slice(0, 8)}...`);
128
+ delete transports[sid];
129
+ deleteSession(sid);
130
+ }
131
+ };
132
+ // 서버 연결
133
+ await server.connect(transport);
134
+ await transport.handleRequest(req, res, req.body);
135
+ return;
136
+ }
137
+ else {
138
+ // 잘못된 요청
139
+ res.status(400).json({
140
+ jsonrpc: "2.0",
141
+ error: {
142
+ code: -32000,
143
+ message: "Invalid request: missing session ID or not an initialization request"
144
+ },
145
+ id: null
146
+ });
147
+ return;
148
+ }
149
+ }
150
+ catch (error) {
151
+ console.error("Error handling MCP POST request:", error);
152
+ if (!res.headersSent) {
153
+ res.status(500).json({
154
+ jsonrpc: "2.0",
155
+ error: {
156
+ code: -32603,
157
+ message: "Internal server error"
158
+ },
159
+ id: null
160
+ });
161
+ }
162
+ }
163
+ });
164
+ // MCP GET 엔드포인트 (SSE 스트림)
165
+ app.get("/mcp", async (req, res) => {
166
+ const sessionId = req.headers["mcp-session-id"];
167
+ if (!sessionId || !transports[sessionId]) {
168
+ res.status(400).send("Invalid or missing session ID");
169
+ return;
170
+ }
171
+ const lastEventId = req.headers["last-event-id"];
172
+ if (lastEventId) {
173
+ console.error(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
174
+ }
175
+ else {
176
+ console.error(`Establishing SSE stream for session ${sessionId.slice(0, 8)}...`);
177
+ }
178
+ try {
179
+ const transport = transports[sessionId].transport;
180
+ await transport.handleRequest(req, res);
181
+ }
182
+ catch (error) {
183
+ console.error("[GET /mcp] Error:", error);
184
+ if (!res.headersSent) {
185
+ res.status(500).send("Internal server error");
186
+ }
187
+ }
188
+ });
189
+ // MCP DELETE 엔드포인트 (세션 종료)
190
+ app.delete("/mcp", async (req, res) => {
191
+ const sessionId = req.headers["mcp-session-id"];
192
+ if (!sessionId || !transports[sessionId]) {
193
+ res.status(400).send("Invalid or missing session ID");
194
+ return;
195
+ }
196
+ console.error(`Session termination request for ${sessionId.slice(0, 8)}...`);
197
+ try {
198
+ const transport = transports[sessionId].transport;
199
+ await transport.handleRequest(req, res);
200
+ delete transports[sessionId];
201
+ deleteSession(sessionId);
202
+ console.error(`Session removed: ${sessionId.slice(0, 8)}...`);
203
+ }
204
+ catch (error) {
205
+ console.error("Error handling session termination:", error);
206
+ if (!res.headersSent) {
207
+ res.status(500).send("Error processing session termination");
208
+ }
209
+ }
210
+ });
211
+ // 서버 시작
212
+ app.listen(port, "0.0.0.0", () => {
213
+ console.error(`✓ Korean Law MCP server (Streamable HTTP) listening on port ${port}`);
214
+ console.error(`✓ MCP endpoint: http://0.0.0.0:${port}/mcp`);
215
+ console.error(`✓ Health check: http://0.0.0.0:${port}/health`);
216
+ });
217
+ // 종료 처리
218
+ process.on("SIGINT", async () => {
219
+ console.error("Shutting down server...");
220
+ for (const sessionId in transports) {
221
+ try {
222
+ await transports[sessionId].transport.close();
223
+ delete transports[sessionId];
224
+ deleteSession(sessionId);
225
+ }
226
+ catch (error) {
227
+ console.error(`Error closing transport ${sessionId}:`, error);
228
+ }
229
+ }
230
+ process.exit(0);
231
+ });
232
+ }
@@ -22,6 +22,8 @@ export declare const VerifyCitationsSchema: z.ZodObject<{
22
22
  apiKey: z.ZodOptional<z.ZodString>;
23
23
  }, z.core.$strip>;
24
24
  export type VerifyCitationsInput = z.infer<typeof VerifyCitationsSchema>;
25
+ export declare function lawNameCandidates(lawName: string): string[];
26
+ export declare function looseMatchLawName(target: string, official: string): boolean;
25
27
  export declare function verifyCitations(apiClient: LawApiClient, input: VerifyCitationsInput): Promise<{
26
28
  content: Array<{
27
29
  type: string;
@@ -33,6 +33,30 @@ const ARTICLE_REGEX = /제\s*(\d+)\s*조(?:\s*의\s*(\d+))?(?:\s*제\s*(\d+)\s*
33
33
  const LAW_NAME_REGEX = /([가-힣][가-힣·ㆍ\s]{0,30}?(?:법률|법|시행령|시행규칙|규칙|규정|조례))$/;
34
34
  // 법령명 앞에 붙는 한국어 접속사·부사·수식어 제거 — "또한 상법" → "상법"
35
35
  const LAW_NAME_STOPWORDS = /^(또한|그리고|하며|따라서|따라|위해|위하여|의한|의하여|따른|해당|관련|이에|아울러|본|이|저|그|또|및|또는|혹은|한편|더불어|이어|이는|즉|결국|결과적으로|실제로|특히)\s+/u;
36
+ // 캡처된 법령명 앞에 내용어 수식어가 남을 수 있음(예: "절도죄는 형법", "이혼시 재산분할은 민법").
37
+ // 앞 어절을 하나씩 떼며 검색 후보를 만든다. 전체(full)를 먼저 두어 다어절 법령명
38
+ // ("전자상거래 등에서의 소비자보호에 관한 법률")은 그대로 매칭되고, 수식어만 붙은 경우는
39
+ // 뒤쪽 후보에서 실제 법령명("형법"·"민법")이 매칭된다. 붙어쓴 법령명은 어절 분리되지 않아 보존.
40
+ export function lawNameCandidates(lawName) {
41
+ const tokens = lawName.split(/\s+/).filter(Boolean);
42
+ const candidates = [];
43
+ for (let i = 0; i < tokens.length; i++) {
44
+ const cand = tokens.slice(i).join(" ");
45
+ if (cand.length >= 2)
46
+ candidates.push(cand);
47
+ }
48
+ return candidates.length > 0 ? candidates : [lawName];
49
+ }
50
+ // 후보 법령명과 법제처 공식 법령명의 느슨한 일치 — 공백 무시 + 접두/약칭 허용.
51
+ // findLaws가 관련도 정렬은 해도 매칭이 전혀 다른 법령일 수 있어 최종 방어선으로 사용.
52
+ export function looseMatchLawName(target, official) {
53
+ const normalize = (s) => s.replace(/\s+/g, "");
54
+ const targetNorm = normalize(target);
55
+ const officialNorm = normalize(official);
56
+ return officialNorm === targetNorm
57
+ || officialNorm.startsWith(targetNorm)
58
+ || targetNorm.startsWith(officialNorm.replace(/(법률|법)$/, "법"));
59
+ }
36
60
  // 인용 바로 뒤 "(제목)"에서 조문 제목 claim 추출 — 내용검증용.
37
61
  // 개정이력·날짜·항호 참조 괄호는 조문 제목이 아니므로 제외.
38
62
  function extractClaimTitle(after) {
@@ -114,23 +138,27 @@ async function verifyOne(apiClient, cite, apiKey) {
114
138
  return `⚠ ${inputLabel} — 법령명 추출 실패 (앞 문맥에 법령명 명시 필요)`;
115
139
  }
116
140
  // 1단계: 법령 검색 — findLaws가 관련도 정렬까지 처리 (민법→난민법 오매칭 방지)
141
+ // 앞 수식어가 남은 캡처("절도죄는 형법")도 후보를 순차 축약하며 실제 법령명을 찾는다.
117
142
  let chosen;
143
+ let fallback; // 어떤 후보도 looseMatch 실패 시 ⚠ 메시지용 (전체 캡처 기준)
118
144
  try {
119
- // searchDisplay=100: "상법"처럼 짧은 법령명이 부분매칭에 밀려 기본 20건에 안 들어올 때 대비
120
- const results = await findLaws(apiClient, cite.lawName, apiKey, 5, 100);
121
- if (results.length === 0) {
145
+ for (const cand of lawNameCandidates(cite.lawName)) {
146
+ // searchDisplay=100: "상법"처럼 짧은 법령명이 부분매칭에 밀려 기본 20건에 안 들어올 때 대비
147
+ const results = await findLaws(apiClient, cand, apiKey, 5, 100);
148
+ if (results.length === 0)
149
+ continue;
150
+ if (!fallback)
151
+ fallback = results[0];
152
+ if (looseMatchLawName(cand, results[0].lawName)) {
153
+ chosen = results[0];
154
+ break;
155
+ }
156
+ }
157
+ if (!fallback) {
122
158
  return `✗ ${inputLabel} — [NOT_FOUND] 법제처 DB에 해당 법령 없음 (법령명 오탈자 또는 존재하지 않는 법령)`;
123
159
  }
124
- chosen = results[0];
125
- // 정확 일치 여부 체크 — findLaws가 정렬은 해도 매칭이 전혀 다른 법령일 수 있음
126
- const normalize = (s) => s.replace(/\s+/g, "");
127
- const targetNorm = normalize(cite.lawName);
128
- const officialNorm = normalize(chosen.lawName);
129
- const looseMatch = officialNorm === targetNorm
130
- || officialNorm.startsWith(targetNorm)
131
- || targetNorm.startsWith(officialNorm.replace(/(법률|법)$/, "법"));
132
- if (!looseMatch) {
133
- return `⚠ ${inputLabel} — 법제처 검색은 '${chosen.lawName}'(으)로만 매칭됨. 법령명 정확성 재확인 필요`;
160
+ if (!chosen) {
161
+ return `⚠ ${inputLabel} — 법제처 검색은 '${fallback.lawName}'(으)로만 매칭됨. 법령명 정확성 재확인 필요`;
134
162
  }
135
163
  }
136
164
  catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "korean-law-mcp",
3
- "version": "4.7.1",
3
+ "version": "4.7.2",
4
4
  "description": "법제처 42개 API → 9개 MCP 도구. 법령·판례·조례·조약 + 다단계 리서치(legal_research, 8 task) + 정밀분석(legal_analysis: 인용검증·판례생사·행위시법·영향그래프) + 시점 비교(time_travel) + 상황별 5단계 안내(action_plan) + 국세청 해석례(nts)",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
@@ -72,6 +72,9 @@
72
72
  "typescript": "^5.9.3",
73
73
  "vitest": "^4.1.9"
74
74
  },
75
+ "overrides": {
76
+ "hono": "^4.12.29"
77
+ },
75
78
  "files": [
76
79
  "build",
77
80
  "README.md",