korean-law-mcp 4.4.0 → 4.4.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.
@@ -233,8 +233,8 @@ export class LawApiClient {
233
233
  if (/시행령|시행규칙/.test(lawName)) {
234
234
  return 'law';
235
235
  }
236
- // 행정규칙: 훈령, 예규, 고시, 지침, 내규
237
- if (/훈령|예규|고시|지침|내규/.test(lawName)) {
236
+ // 행정규칙: 훈령, 예규, 고시, 지침, 내규, 세칙 (규정/규칙 단독은 시행규칙 오분류 위험 → 4차 fallback에 위임)
237
+ if (/훈령|예규|고시|지침|내규|세칙/.test(lawName)) {
238
238
  return 'admin';
239
239
  }
240
240
  // 일반 법령 (법, 규정 등)
@@ -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
+ }
@@ -9,6 +9,10 @@ import type { McpTool } from "./lib/types.js";
9
9
  * 모든 MCP 도구 정의
10
10
  */
11
11
  export declare const allTools: McpTool[];
12
+ /**
13
+ * Zod 스키마 → MCP 광고용 JSON Schema 변환 (apiKey 숨김 포함)
14
+ */
15
+ export declare function toMcpInputSchema(schema: unknown): any;
12
16
  /** 노출/전체 도구 수 — 헬스체크 등 표기용 파생값 (하드코딩 금지) */
13
17
  export declare const TOOL_COUNTS: {
14
18
  exposed: number;
@@ -680,11 +680,13 @@ export const allTools = [
680
680
  },
681
681
  ];
682
682
  /**
683
- * ZodEffects(.refine(), .transform() 등)를 벗겨내고 내부 ZodObject를 반환
683
+ * Zod 스키마 → MCP 광고용 JSON Schema 변환 (apiKey 숨김 포함)
684
684
  */
685
- function toMcpInputSchema(schema) {
685
+ export function toMcpInputSchema(schema) {
686
686
  // Zod v4: z.toJSONSchema()로 직접 변환 (zod-to-json-schema는 Zod v4 미지원)
687
- const rawSchema = z.toJSONSchema(schema);
687
+ // io:"input" 필수 — 기본 "output" 모드는 .default() 필드를 required로 직렬화함
688
+ // (legal_research.task, search_law.display가 required로 광고되던 버그, v4.4.1)
689
+ const rawSchema = z.toJSONSchema(schema, { io: "input" });
688
690
  if (rawSchema?.type === "object" && rawSchema?.properties) {
689
691
  // apiKey는 HTTP 헤더(session-state)로 전달되는 게 정식 경로 — 광고 스키마에서 숨김.
690
692
  // Zod parse는 여전히 수용하므로 인자로 넘기는 기존 클라이언트도 동작.
@@ -9,7 +9,7 @@ import { formatToolError, notFoundResponse } from "../lib/errors.js";
9
9
  import { getLawSiteBaseUrl } from "../lib/law-url-config.js";
10
10
  const LAW_BASE_URL = getLawSiteBaseUrl();
11
11
  export const GetAnnexesSchema = z.object({
12
- lawName: z.string().describe("법령명 (예: '관세법'). 별표를 바로 지정하려면 '... 별표4'처럼 함께 입력 가능"),
12
+ lawName: z.string().describe("법령명 (예: '관세법'). 별표를 바로 지정하려면 '... 별표4' 또는 '... 별표1의2'처럼 함께 입력 가능"),
13
13
  knd: z.enum(["1", "2", "3", "4", "5"]).optional().describe("1=별표, 2=서식, 3=부칙별표, 4=부칙서식, 5=전체"),
14
14
  bylSeq: z.string().optional().describe("별표번호 (예: '000300'). 지정 시 해당 별표 파일을 다운로드하여 텍스트로 추출"),
15
15
  annexNo: z.string().optional().describe("별표 번호 (예: '4', '별표4', '제4호'). bylSeq 대체 입력"),
@@ -29,8 +29,9 @@ export async function getAnnexes(apiClient, input) {
29
29
  const json = JSON.parse(jsonText);
30
30
  const adminResult = json?.admRulBylSearch;
31
31
  const licResult = json?.licBylSearch;
32
- if (adminResult?.admbyl)
33
- return { list: toArray(adminResult.admbyl), type: "admin" };
32
+ // 법제처 행정규칙 별표 응답의 배열 키는 admrulbyl (admbyl 아님). 구버전 호환 위해 admbyl도 폴백.
33
+ if (adminResult?.admrulbyl ?? adminResult?.admbyl)
34
+ return { list: toArray(adminResult.admrulbyl ?? adminResult.admbyl), type: "admin" };
34
35
  if (licResult?.ordinbyl)
35
36
  return { list: toArray(licResult.ordinbyl), type: "ordinance" };
36
37
  if (licResult?.licbyl)
@@ -109,7 +110,7 @@ export async function getAnnexes(apiClient, input) {
109
110
  const filtered = filterByRelatedLawName(annexList, normalizedLawName);
110
111
  // 별표 선택값 지정 시 → 해당 별표 파일 다운로드 + 텍스트 추출
111
112
  if (annexSelector) {
112
- return await extractAnnexContent(filtered, annexSelector, normalizedLawName);
113
+ return await extractAnnexContent(filtered, annexSelector, normalizedLawName, input.knd);
113
114
  }
114
115
  // 별표 선택값 미지정 → 기존 목록 반환
115
116
  return formatAnnexList(filtered, lawType, input, normalizedLawName);
@@ -119,9 +120,9 @@ export async function getAnnexes(apiClient, input) {
119
120
  }
120
121
  }
121
122
  // ─── 별표 텍스트 추출 ─────────────────────────────────
122
- async function extractAnnexContent(annexList, annexSelector, normalizedLawName) {
123
- // bylSeq / annexNo / lawName 내 힌트로 유연 매칭
124
- const matched = findMatchingAnnex(annexList, annexSelector);
123
+ async function extractAnnexContent(annexList, annexSelector, normalizedLawName, knd) {
124
+ // bylSeq / annexNo / lawName 내 힌트로 유연 매칭 (별표/서식 구분 위해 knd 전달)
125
+ const matched = findMatchingAnnex(annexList, annexSelector, knd);
125
126
  if (!matched) {
126
127
  const availableBylSeq = annexList.map((a) => a.별표번호).filter(Boolean).slice(0, 20).join(", ");
127
128
  return notFoundResponse(`별표 선택값 "${annexSelector}"에 해당하는 항목을 찾을 수 없습니다. (법령: ${normalizedLawName})`, [
@@ -219,8 +220,9 @@ function formatAnnexList(annexList, lawType, input, normalizedLawName) {
219
220
  else if (lawType === "admin") {
220
221
  if (annex.관련행정규칙명)
221
222
  resultText += ` 행정규칙: ${annex.관련행정규칙명}\n`;
222
- if (annex.소관부처)
223
- resultText += ` 소관부처: ${annex.소관부처}\n`;
223
+ const dept = annex.소관부처명 || annex.소관부처;
224
+ if (dept)
225
+ resultText += ` 소관부처: ${dept}\n`;
224
226
  }
225
227
  else {
226
228
  if (annex.관련법령명)
@@ -245,24 +247,45 @@ function extractParentLawName(lawName) {
245
247
  }
246
248
  function parseLawNameAndHint(lawName) {
247
249
  const trimmedLawName = lawName.trim();
248
- const annexHintMatch = trimmedLawName.match(/\[?\s*(별표|서식)\s*(?:제)?\s*(\d{1,6})\s*(?:호)?\s*\]?/);
250
+ // "별표1", "별표 제1호", "별표 1의2"(= 별표 제1호의2) 모두 매칭. 의-번호는 별도 캡처해 법령명에 남지 않게 한다.
251
+ const annexHintMatch = trimmedLawName.match(/\[?\s*(별표|서식)\s*(?:제)?\s*(\d{1,6})\s*(?:호)?\s*(?:의\s*(\d{1,2}))?\s*\]?/);
249
252
  if (!annexHintMatch) {
250
253
  return { normalizedLawName: trimmedLawName };
251
254
  }
252
- const parsedAnnexNo = Number.parseInt(annexHintMatch[2], 10);
255
+ const mainNo = Number.parseInt(annexHintMatch[2], 10);
256
+ const subNo = annexHintMatch[3] ? Number.parseInt(annexHintMatch[3], 10) : null;
253
257
  const normalizedLawName = trimmedLawName
254
258
  .replace(annexHintMatch[0], " ")
255
259
  .replace(/\s+/g, " ")
256
260
  .trim();
261
+ if (Number.isNaN(mainNo)) {
262
+ return { normalizedLawName: normalizedLawName || trimmedLawName };
263
+ }
264
+ // 의-번호가 있으면 법제처 별표번호 6자리 코드(AAAABB)로 변환 (별표 1의2 → "000102").
265
+ // 없으면 기존 동작 유지(정수 문자열 → buildSelectorCandidates가 6자리 코드 후보 생성).
266
+ const annexNo = subNo != null
267
+ ? String(mainNo).padStart(4, "0") + String(subNo).padStart(2, "0")
268
+ : String(mainNo);
257
269
  return {
258
270
  normalizedLawName: normalizedLawName || trimmedLawName,
259
- annexNo: Number.isNaN(parsedAnnexNo) ? undefined : String(parsedAnnexNo)
271
+ annexNo
260
272
  };
261
273
  }
262
- function findMatchingAnnex(annexList, annexSelector) {
274
+ /**
275
+ * 별표 선택값으로 항목 매칭.
276
+ *
277
+ * 자치법규 등에서 [별표 N]과 [별지 제N호서식]이 동일 별표번호(bylSeq)를 공유하는 경우가
278
+ * 있어, 번호만으로 find() 하면 목록 순서상 먼저 오는 항목(주로 서식)이 잘못 선택된다.
279
+ * (예: 서울특별시 건축 조례 — [별표4] 대지안의 공지기준 / [별지 제4호서식] 공개공지 관리대장이
280
+ * 모두 별표번호 000400을 가짐.)
281
+ * 따라서 번호가 일치하는 후보를 모두 모은 뒤, knd(별표/서식 의도)로 별표종류를 구분해
282
+ * 올바른 항목을 고른다. knd 미지정 시 표(별표)를 서식보다 우선한다.
283
+ */
284
+ function findMatchingAnnex(annexList, annexSelector, knd) {
263
285
  const selectorCandidates = buildSelectorCandidates(annexSelector);
264
286
  const selectorNumbers = extractSelectorNumbers(annexSelector);
265
- return annexList.find((annex) => {
287
+ // 번호/제목으로 매칭되는 후보 "전체" 수집 (find → filter)
288
+ const matches = annexList.filter((annex) => {
266
289
  const annexNum = String(annex.별표번호 || "").trim();
267
290
  const annexTitle = String(annex.별표명 || "");
268
291
  if (annexNum && selectorCandidates.has(annexNum)) {
@@ -270,6 +293,23 @@ function findMatchingAnnex(annexList, annexSelector) {
270
293
  }
271
294
  return selectorNumbers.some((num) => titleMatchesAnnexNumber(annexTitle, num));
272
295
  });
296
+ if (matches.length === 0)
297
+ return undefined;
298
+ if (matches.length === 1)
299
+ return matches[0];
300
+ // 별표번호 충돌 → 별표종류("별표"/"서식")로 구분
301
+ const isForm = (a) => /서식/.test(String(a.별표종류 || ""));
302
+ const isTable = (a) => /별표/.test(String(a.별표종류 || ""));
303
+ if (knd === "2" || knd === "4") {
304
+ // 서식을 명시적으로 요청
305
+ return matches.find(isForm) || matches[0];
306
+ }
307
+ if (knd === "1" || knd === "3") {
308
+ // 별표를 명시적으로 요청
309
+ return matches.find(isTable) || matches[0];
310
+ }
311
+ // knd 미지정/전체(5): 표(별표)를 서식보다 우선
312
+ return matches.find(isTable) || matches[0];
273
313
  }
274
314
  function buildSelectorCandidates(selector) {
275
315
  const candidates = new Set();
@@ -4,7 +4,8 @@
4
4
  * verify_citations·cite_check·applicable_law·impact_map을 mode 파라미터로
5
5
  * 통합해 MCP 노출 도구 수를 줄인다. 원본 도구는 allTools에 그대로 남아
6
6
  * 직접 호출/execute_tool 경유가 계속 동작한다 (하위호환).
7
- * 세부 옵션(deepScan, includeMermaid 등)은 원본 도구의 기본값을 따른다.
7
+ * 비용이 큰 옵션(deepScan, includeOrdinances, includeMermaid 등)은
8
+ * 패스스루로 노출 — 기본값은 원본 도구와 동일 (v4.4.1).
8
9
  */
9
10
  import { z } from "zod";
10
11
  import type { LawApiClient } from "../lib/api-client.js";
@@ -21,6 +22,11 @@ export declare const LegalAnalysisSchema: z.ZodObject<{
21
22
  lawName: z.ZodOptional<z.ZodString>;
22
23
  jo: z.ZodOptional<z.ZodString>;
23
24
  date: z.ZodOptional<z.ZodString>;
25
+ maxCitations: z.ZodOptional<z.ZodNumber>;
26
+ display: z.ZodOptional<z.ZodNumber>;
27
+ deepScan: z.ZodOptional<z.ZodBoolean>;
28
+ includeOrdinances: z.ZodOptional<z.ZodBoolean>;
29
+ includeMermaid: z.ZodOptional<z.ZodBoolean>;
24
30
  apiKey: z.ZodOptional<z.ZodString>;
25
31
  }, z.core.$strip>;
26
32
  export type LegalAnalysisInput = z.infer<typeof LegalAnalysisSchema>;
@@ -4,7 +4,8 @@
4
4
  * verify_citations·cite_check·applicable_law·impact_map을 mode 파라미터로
5
5
  * 통합해 MCP 노출 도구 수를 줄인다. 원본 도구는 allTools에 그대로 남아
6
6
  * 직접 호출/execute_tool 경유가 계속 동작한다 (하위호환).
7
- * 세부 옵션(deepScan, includeMermaid 등)은 원본 도구의 기본값을 따른다.
7
+ * 비용이 큰 옵션(deepScan, includeOrdinances, includeMermaid 등)은
8
+ * 패스스루로 노출 — 기본값은 원본 도구와 동일 (v4.4.1).
8
9
  */
9
10
  import { z } from "zod";
10
11
  import { verifyCitations } from "./verify-citations.js";
@@ -24,6 +25,16 @@ export const LegalAnalysisSchema = z.object({
24
25
  .describe("[impact_map 필수, applicable_law 선택] 조문 번호 (예: '제103조', '제10조의2')"),
25
26
  date: z.string().optional()
26
27
  .describe("[applicable_law 필수] 기준일 — 행위·계약·처분 시점 (예: '2023-05-10', '20230510')"),
28
+ maxCitations: z.number().min(1).max(30).optional()
29
+ .describe("[verify_citations] 검증할 최대 인용 개수 (기본 15, 많을수록 느림)"),
30
+ display: z.number().min(1).max(50).optional()
31
+ .describe("[cite_check] 후속 인용 판례 최대 표시 수 (기본 20)"),
32
+ deepScan: z.boolean().optional()
33
+ .describe("[cite_check] 후속 인용 상위 판례 본문 정밀 스캔 (기본 true, false면 빠르지만 변경·폐기 감지 생략)"),
34
+ includeOrdinances: z.boolean().optional()
35
+ .describe("[impact_map] 자치법규 인용 검색 포함 (기본 true, false면 전국 조례 팬아웃 생략)"),
36
+ includeMermaid: z.boolean().optional()
37
+ .describe("[impact_map] mermaid 그래프 코드 출력 (기본 true)"),
27
38
  apiKey: z.string().optional(),
28
39
  });
29
40
  function inputError(message) {
@@ -35,11 +46,18 @@ export async function legalAnalysis(apiClient, input) {
35
46
  case "verify_citations":
36
47
  if (!input.text)
37
48
  return inputError("mode=verify_citations에는 text가 필요합니다.");
38
- return verifyCitations(apiClient, { text: input.text, maxCitations: 15, apiKey });
49
+ return verifyCitations(apiClient, {
50
+ text: input.text, maxCitations: input.maxCitations ?? 15, apiKey,
51
+ });
39
52
  case "cite_check":
40
53
  if (!input.caseNumber)
41
54
  return inputError("mode=cite_check에는 caseNumber가 필요합니다.");
42
- return citeCheck(apiClient, { caseNumber: input.caseNumber, display: 20, deepScan: true, apiKey });
55
+ return citeCheck(apiClient, {
56
+ caseNumber: input.caseNumber,
57
+ display: input.display ?? 20,
58
+ deepScan: input.deepScan ?? true,
59
+ apiKey,
60
+ });
43
61
  case "applicable_law":
44
62
  if (!input.lawName || !input.date)
45
63
  return inputError("mode=applicable_law에는 lawName과 date가 필요합니다.");
@@ -49,7 +67,9 @@ export async function legalAnalysis(apiClient, input) {
49
67
  return inputError("mode=impact_map에는 lawName과 jo가 필요합니다.");
50
68
  return impactMap(apiClient, {
51
69
  lawName: input.lawName, jo: input.jo,
52
- includeOrdinances: true, includeMermaid: true, apiKey,
70
+ includeOrdinances: input.includeOrdinances ?? true,
71
+ includeMermaid: input.includeMermaid ?? true,
72
+ apiKey,
53
73
  });
54
74
  }
55
75
  }
@@ -4,10 +4,13 @@
4
4
  * 기존 chain_* 8개를 task 파라미터 하나로 통합해 MCP 노출 도구 수와
5
5
  * ListTools 컨텍스트 비용을 줄인다. 기존 chain_* 도구는 allTools에
6
6
  * 그대로 남아 직접 호출/execute_tool 경유가 계속 동작한다 (하위호환).
7
+ *
8
+ * task별 허용 scenario는 체인 스키마(chains.ts)에서 직접 파생 —
9
+ * 별도 호환표를 두지 않아 체인 쪽 enum 변경 시 자동 추종된다 (v4.4.1).
7
10
  */
8
11
  import { z } from "zod";
9
12
  import type { LawApiClient } from "../lib/api-client.js";
10
- import { chainFullResearch } from "./chains.js";
13
+ import type { LooseToolResponse } from "../lib/types.js";
11
14
  export declare const LegalResearchSchema: z.ZodObject<{
12
15
  query: z.ZodOptional<z.ZodString>;
13
16
  task: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
@@ -49,6 +52,17 @@ export declare const LegalResearchSchema: z.ZodObject<{
49
52
  apiKey: z.ZodOptional<z.ZodString>;
50
53
  }, z.core.$strip>;
51
54
  export type LegalResearchInput = z.infer<typeof LegalResearchSchema>;
52
- type ToolResponse = Awaited<ReturnType<typeof chainFullResearch>>;
55
+ type ToolResponse = LooseToolResponse;
56
+ /**
57
+ * 체인 스키마의 scenario 필드로 입력 scenario를 검증한다.
58
+ * 비호환이면 버리고(자동 감지로 폴백) 경고 노트를 함께 반환 —
59
+ * 호출 LLM이 자기 파라미터가 무시된 것을 알 수 있게 한다.
60
+ */
61
+ export declare function pickScenario<S extends z.ZodType>(schema: S, scenario: string | undefined, task: string): {
62
+ value: z.infer<S>;
63
+ note?: string;
64
+ };
65
+ /** 경고 노트를 응답 첫 줄에 주입 */
66
+ export declare function withNote(note: string | undefined, res: ToolResponse): ToolResponse;
53
67
  export declare function legalResearch(apiClient: LawApiClient, input: LegalResearchInput): Promise<ToolResponse>;
54
68
  export {};
@@ -4,9 +4,12 @@
4
4
  * 기존 chain_* 8개를 task 파라미터 하나로 통합해 MCP 노출 도구 수와
5
5
  * ListTools 컨텍스트 비용을 줄인다. 기존 chain_* 도구는 allTools에
6
6
  * 그대로 남아 직접 호출/execute_tool 경유가 계속 동작한다 (하위호환).
7
+ *
8
+ * task별 허용 scenario는 체인 스키마(chains.ts)에서 직접 파생 —
9
+ * 별도 호환표를 두지 않아 체인 쪽 enum 변경 시 자동 추종된다 (v4.4.1).
7
10
  */
8
11
  import { z } from "zod";
9
- import { chainLawSystem, chainActionBasis, chainDisputePrep, chainAmendmentTrack, chainOrdinanceCompare, chainFullResearch, chainProcedureDetail, chainDocumentReview, } from "./chains.js";
12
+ import { chainLawSystem, chainLawSystemSchema, chainActionBasis, chainActionBasisSchema, chainDisputePrep, chainAmendmentTrack, chainAmendmentTrackSchema, chainOrdinanceCompare, chainOrdinanceCompareSchema, chainFullResearch, chainFullResearchSchema, chainProcedureDetail, chainProcedureDetailSchema, chainDocumentReview, } from "./chains.js";
10
13
  export const LegalResearchSchema = z.object({
11
14
  query: z.string().optional()
12
15
  .describe("자연어 질문/법령명/키워드 (예: '음주운전 처벌 기준', '관세법 체계'). document_review 외 모든 task에서 필수"),
@@ -38,68 +41,84 @@ export const LegalResearchSchema = z.object({
38
41
  .describe("[document_review] 최대 분석 조항 수 (기본 15)"),
39
42
  apiKey: z.string().optional(),
40
43
  });
41
- /** task별 허용 시나리오 — 비호환 시나리오는 무시하고 자동 감지에 맡긴다 */
42
- const TASK_SCENARIOS = {
43
- law_system: new Set(["delegation", "impact"]),
44
- action_basis: new Set(["penalty"]),
45
- amendment_track: new Set(["timeline", "time_travel"]),
46
- ordinance_compare: new Set(["compliance"]),
47
- full_research: new Set(["customs", "action_plan"]),
48
- procedure_detail: new Set(["manual"]),
49
- };
50
44
  function inputError(message) {
51
45
  return { content: [{ type: "text", text: message }], isError: true };
52
46
  }
47
+ /**
48
+ * 체인 스키마의 scenario 필드로 입력 scenario를 검증한다.
49
+ * 비호환이면 버리고(자동 감지로 폴백) 경고 노트를 함께 반환 —
50
+ * 호출 LLM이 자기 파라미터가 무시된 것을 알 수 있게 한다.
51
+ */
52
+ export function pickScenario(schema, scenario, task) {
53
+ const result = schema.safeParse(scenario);
54
+ if (result.success)
55
+ return { value: result.data };
56
+ return {
57
+ value: undefined,
58
+ note: `⚠ scenario=${scenario}는 task=${task}와 비호환이라 무시하고 자동 감지로 대체했습니다.`,
59
+ };
60
+ }
61
+ /** 경고 노트를 응답 첫 줄에 주입 */
62
+ export function withNote(note, res) {
63
+ if (!note)
64
+ return res;
65
+ return { ...res, content: [{ type: "text", text: note }, ...res.content] };
66
+ }
67
+ /** scenario 필드가 없는 task에 scenario가 들어온 경우의 경고 */
68
+ function droppedNote(scenario, task) {
69
+ return scenario
70
+ ? `⚠ task=${task}는 scenario를 지원하지 않아 scenario=${scenario}를 무시했습니다.`
71
+ : undefined;
72
+ }
53
73
  export async function legalResearch(apiClient, input) {
54
74
  const task = input.task ?? "full_research";
55
75
  if (task === "document_review") {
56
76
  if (!input.text)
57
77
  return inputError("task=document_review에는 text(문서 전문)가 필요합니다.");
58
- return chainDocumentReview(apiClient, {
78
+ return withNote(droppedNote(input.scenario, task), await chainDocumentReview(apiClient, {
59
79
  text: input.text,
60
80
  maxClauses: input.maxClauses ?? 15,
61
81
  apiKey: input.apiKey,
62
- });
82
+ }));
63
83
  }
64
84
  if (!input.query)
65
85
  return inputError(`task=${task}에는 query가 필요합니다.`);
66
86
  const query = input.query;
67
- // 비호환 시나리오는 버리고 각 체인의 자동 감지에 맡긴다
68
- const scenario = input.scenario && TASK_SCENARIOS[task]?.has(input.scenario)
69
- ? input.scenario
70
- : undefined;
71
87
  const apiKey = input.apiKey;
72
88
  switch (task) {
73
- case "law_system":
74
- return chainLawSystem(apiClient, {
75
- query, articles: input.articles,
76
- scenario: scenario, apiKey,
77
- });
78
- case "action_basis":
79
- return chainActionBasis(apiClient, {
80
- query, scenario: scenario, apiKey,
81
- });
89
+ case "law_system": {
90
+ const { value: scenario, note } = pickScenario(chainLawSystemSchema.shape.scenario, input.scenario, task);
91
+ return withNote(note, await chainLawSystem(apiClient, {
92
+ query, articles: input.articles, scenario, apiKey,
93
+ }));
94
+ }
95
+ case "action_basis": {
96
+ const { value: scenario, note } = pickScenario(chainActionBasisSchema.shape.scenario, input.scenario, task);
97
+ return withNote(note, await chainActionBasis(apiClient, { query, scenario, apiKey }));
98
+ }
82
99
  case "dispute_prep":
83
- return chainDisputePrep(apiClient, { query, domain: input.domain, apiKey });
84
- case "amendment_track":
85
- return chainAmendmentTrack(apiClient, {
86
- query, mst: input.mst, lawId: input.lawId,
87
- scenario: scenario,
100
+ return withNote(droppedNote(input.scenario, task), await chainDisputePrep(apiClient, { query, domain: input.domain, apiKey }));
101
+ case "amendment_track": {
102
+ const { value: scenario, note } = pickScenario(chainAmendmentTrackSchema.shape.scenario, input.scenario, task);
103
+ return withNote(note, await chainAmendmentTrack(apiClient, {
104
+ query, mst: input.mst, lawId: input.lawId, scenario,
88
105
  fromDate: input.fromDate, toDate: input.toDate, apiKey,
89
- });
90
- case "ordinance_compare":
91
- return chainOrdinanceCompare(apiClient, {
92
- query, parentLaw: input.parentLaw,
93
- scenario: scenario, apiKey,
94
- });
95
- case "procedure_detail":
96
- return chainProcedureDetail(apiClient, {
97
- query, scenario: scenario, apiKey,
98
- });
106
+ }));
107
+ }
108
+ case "ordinance_compare": {
109
+ const { value: scenario, note } = pickScenario(chainOrdinanceCompareSchema.shape.scenario, input.scenario, task);
110
+ return withNote(note, await chainOrdinanceCompare(apiClient, {
111
+ query, parentLaw: input.parentLaw, scenario, apiKey,
112
+ }));
113
+ }
114
+ case "procedure_detail": {
115
+ const { value: scenario, note } = pickScenario(chainProcedureDetailSchema.shape.scenario, input.scenario, task);
116
+ return withNote(note, await chainProcedureDetail(apiClient, { query, scenario, apiKey }));
117
+ }
99
118
  case "full_research":
100
- default:
101
- return chainFullResearch(apiClient, {
102
- query, scenario: scenario, apiKey,
103
- });
119
+ default: {
120
+ const { value: scenario, note } = pickScenario(chainFullResearchSchema.shape.scenario, input.scenario, task);
121
+ return withNote(note, await chainFullResearch(apiClient, { query, scenario, apiKey }));
122
+ }
104
123
  }
105
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "korean-law-mcp",
3
- "version": "4.4.0",
3
+ "version": "4.4.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",