korean-law-mcp 4.9.2 → 4.9.4

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,39 @@
1
+ /**
2
+ * 별표 정본 링크 해석 (#77)
3
+ *
4
+ * 법제처 licbyl 검색 인덱스(lawSearch target=licbyl)의 별표 파일링크는 별표 파일
5
+ * 재업로드를 늦게 반영해 구본(결함본)을 가리킬 수 있다. 실증: 「각급 법원의 설치와
6
+ * 관할구역에 관한 법률」 별표3 — licbyl 링크는 2026-03-17 개정(인천고등법원 신설
7
+ * 재편)에서 서울고법 블록의 인천 삭제만 반영되고 인천고법 블록 추가가 누락된
8
+ * 결함 파일을 가리켰고, 현행 본문(lawService target=law)의 별표단위 링크가 정본.
9
+ * 개정으로 신설된 별표(별표11 해사국제상사법원)는 licbyl 목록에 아예 없었다.
10
+ *
11
+ * 따라서 법령(law) 타입 별표 추출 시 현행 본문의 별표단위 링크를 우선 사용하고,
12
+ * 조회 실패 시 licbyl 링크로 폴백한다.
13
+ */
14
+ import type { LawApiClient } from "./api-client.js";
15
+ export interface LawAnnexUnit {
16
+ /** 별표번호(4) + 가지번호(2) — licbyl 별표번호와 같은 6자리 코드 */
17
+ code6: string;
18
+ /** 별표구분: "별표" | "서식" 등 */
19
+ kind: string;
20
+ title: string;
21
+ hwpLink?: string;
22
+ pdfLink?: string;
23
+ }
24
+ /** lawService(target=law) JSON 응답에서 별표단위 목록 추출 (순수 함수) */
25
+ export declare function parseLawAnnexUnits(jsonText: string): LawAnnexUnit[];
26
+ /**
27
+ * 별표단위 후보 선택.
28
+ * code6(licbyl 매칭 항목의 별표번호)이 있으면 그것으로, 없으면 selector 후보 집합으로
29
+ * 매칭한다. 같은 번호에 별표/서식이 공존하면 kind(licbyl 별표종류) → knd 의도 →
30
+ * 별표 우선 순으로 고른다.
31
+ */
32
+ export declare function pickAnnexUnit(units: LawAnnexUnit[], opts: {
33
+ code6?: string;
34
+ kind?: string;
35
+ selectorCandidates?: Set<string>;
36
+ knd?: string;
37
+ }): LawAnnexUnit | undefined;
38
+ /** 현행 법령 본문에서 별표단위 목록 조회 */
39
+ export declare function fetchLawAnnexUnits(apiClient: LawApiClient, mst: string, apiKey?: string): Promise<LawAnnexUnit[]>;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 별표 정본 링크 해석 (#77)
3
+ *
4
+ * 법제처 licbyl 검색 인덱스(lawSearch target=licbyl)의 별표 파일링크는 별표 파일
5
+ * 재업로드를 늦게 반영해 구본(결함본)을 가리킬 수 있다. 실증: 「각급 법원의 설치와
6
+ * 관할구역에 관한 법률」 별표3 — licbyl 링크는 2026-03-17 개정(인천고등법원 신설
7
+ * 재편)에서 서울고법 블록의 인천 삭제만 반영되고 인천고법 블록 추가가 누락된
8
+ * 결함 파일을 가리켰고, 현행 본문(lawService target=law)의 별표단위 링크가 정본.
9
+ * 개정으로 신설된 별표(별표11 해사국제상사법원)는 licbyl 목록에 아예 없었다.
10
+ *
11
+ * 따라서 법령(law) 타입 별표 추출 시 현행 본문의 별표단위 링크를 우선 사용하고,
12
+ * 조회 실패 시 licbyl 링크로 폴백한다.
13
+ */
14
+ /** 링크 필드가 배열(이미지 다페이지 등)로 올 수 있어 첫 값만 취한다 */
15
+ const firstLink = (v) => {
16
+ const raw = Array.isArray(v) ? v[0] : v;
17
+ const s = raw == null ? "" : String(raw).trim();
18
+ return s ? s : undefined;
19
+ };
20
+ /** lawService(target=law) JSON 응답에서 별표단위 목록 추출 (순수 함수) */
21
+ export function parseLawAnnexUnits(jsonText) {
22
+ let json;
23
+ try {
24
+ json = JSON.parse(jsonText);
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ const raw = json?.법령?.별표?.별표단위;
30
+ const units = raw == null ? [] : Array.isArray(raw) ? raw : [raw];
31
+ return units
32
+ .map((u) => ({
33
+ code6: String(u.별표번호 ?? "").trim().padStart(4, "0") +
34
+ String(u.별표가지번호 ?? "0").trim().padStart(2, "0"),
35
+ kind: String(u.별표구분 ?? ""),
36
+ title: String(u.별표제목 ?? ""),
37
+ hwpLink: firstLink(u.별표서식파일링크),
38
+ pdfLink: firstLink(u.별표서식PDF파일링크),
39
+ }))
40
+ .filter((u) => (u.hwpLink || u.pdfLink) && u.code6 !== "000000");
41
+ }
42
+ /**
43
+ * 별표단위 후보 선택.
44
+ * code6(licbyl 매칭 항목의 별표번호)이 있으면 그것으로, 없으면 selector 후보 집합으로
45
+ * 매칭한다. 같은 번호에 별표/서식이 공존하면 kind(licbyl 별표종류) → knd 의도 →
46
+ * 별표 우선 순으로 고른다.
47
+ */
48
+ export function pickAnnexUnit(units, opts) {
49
+ const code6 = (opts.code6 || "").trim();
50
+ const candidates = code6
51
+ ? units.filter((u) => u.code6 === code6)
52
+ : opts.selectorCandidates
53
+ ? units.filter((u) => opts.selectorCandidates.has(u.code6))
54
+ : [];
55
+ if (candidates.length === 0)
56
+ return undefined;
57
+ if (candidates.length === 1)
58
+ return candidates[0];
59
+ if (opts.kind) {
60
+ const same = candidates.find((u) => u.kind === opts.kind);
61
+ if (same)
62
+ return same;
63
+ }
64
+ const wantForm = opts.knd === "2" || opts.knd === "4";
65
+ return (candidates.find((u) => (wantForm ? /서식/.test(u.kind) : /별표/.test(u.kind))) ||
66
+ candidates[0]);
67
+ }
68
+ /** 현행 법령 본문에서 별표단위 목록 조회 */
69
+ export async function fetchLawAnnexUnits(apiClient, mst, apiKey) {
70
+ const text = await apiClient.fetchApi({
71
+ endpoint: "lawService.do",
72
+ target: "law",
73
+ type: "JSON",
74
+ extraParams: { MST: mst },
75
+ apiKey,
76
+ });
77
+ return parseLawAnnexUnits(text);
78
+ }
@@ -21,10 +21,16 @@ export function getExternalHttpsProxyConfig() {
21
21
  const proxyAuthorization = username
22
22
  ? `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
23
23
  : undefined;
24
+ // TLS 검증 비활성화는 진단용 임시 우회다. 운영(NODE_ENV=production)에서는
25
+ // 무시하고 항상 검증한다 — 끄면 프록시 구간이 중간자 공격에 무방비가 된다.
26
+ const disableTls = process.env.LAW_EXTERNAL_TLS_REJECT_UNAUTHORIZED === "0";
27
+ if (disableTls && process.env.NODE_ENV === "production") {
28
+ console.error("⚠️ LAW_EXTERNAL_TLS_REJECT_UNAUTHORIZED=0 은 운영 환경에서 무시됩니다 (TLS 검증 유지).");
29
+ }
24
30
  return {
25
31
  host: proxyUrl.hostname,
26
32
  port: proxyUrl.port ? Number(proxyUrl.port) : 80,
27
- rejectUnauthorized: process.env.LAW_EXTERNAL_TLS_REJECT_UNAUTHORIZED !== "0",
33
+ rejectUnauthorized: !disableTls || process.env.NODE_ENV === "production",
28
34
  proxyAuthorization,
29
35
  };
30
36
  }
@@ -70,9 +70,15 @@ export async function followLawAntibot(response, originalUrl, headers, timeout,
70
70
  if (!path)
71
71
  return hopped ? current : null;
72
72
  // path는 보통 절대경로(/DRF/...). 원본 URL의 origin에 붙인다.
73
+ // path가 절대 URL이면 base가 무시되므로, 홉 대상이 원본과 같은 호스트인지
74
+ // 반드시 확인한다 — 아니면 응답 하나로 임의 호스트 요청을 유도할 수 있다.
73
75
  let nextUrl;
74
76
  try {
75
- nextUrl = new URL(path, originalUrl).toString();
77
+ const parsed = new URL(path, originalUrl);
78
+ if (parsed.hostname !== new URL(originalUrl).hostname) {
79
+ return hopped ? current : null;
80
+ }
81
+ nextUrl = parsed.toString();
76
82
  }
77
83
  catch {
78
84
  return hopped ? current : null;
@@ -6,11 +6,24 @@
6
6
  * 참고: @modelcontextprotocol/sdk/examples/server/simpleStatelessStreamableHttp.js
7
7
  */
8
8
  import express from "express";
9
+ import { timingSafeEqual } from "node:crypto";
9
10
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
11
  import { requestContext } from "../lib/session-state.js";
11
12
  import { maskSensitiveUrl } from "../lib/fetch-with-retry.js";
12
13
  import { TOOL_COUNTS } from "../tool-registry.js";
13
14
  import { VERSION } from "../version.js";
15
+ /** 타이밍 공격 내성 문자열 비교 (길이가 달라도 throw하지 않음) */
16
+ function safeEqual(a, b) {
17
+ const bufA = Buffer.from(a);
18
+ const bufB = Buffer.from(b);
19
+ if (bufA.length !== bufB.length)
20
+ return false;
21
+ return timingSafeEqual(bufA, bufB);
22
+ }
23
+ /** `Authorization: Bearer x` → `x` (Bearer 접두사가 없으면 원문) */
24
+ function bearerValue(raw) {
25
+ return raw ? raw.replace(/^Bearer\s+/i, "") : "";
26
+ }
14
27
  /**
15
28
  * 에러 메시지에서 민감 정보(API 키 포함 URL) scrub.
16
29
  * MCP 응답/서버 로그 양쪽에 적용되어야 함.
@@ -45,6 +58,80 @@ export async function startHTTPServer(createServer, port) {
45
58
  next();
46
59
  });
47
60
  }
61
+ // ── Origin 검증 (DNS rebinding 방어) ──────────────────────────────────────
62
+ // MCP 명세는 로컬/원격 HTTP 트랜스포트에 Origin 검증을 요구한다. 브라우저가 심은
63
+ // Origin을 그대로 통과시키면, 사용자가 악성 페이지를 여는 것만으로 그 페이지가
64
+ // 이 서버를 대신 호출하고 응답까지 읽어갈 수 있다(CORS가 '*'면 특히).
65
+ // 정책: Origin 헤더가 없는 요청(일반 MCP 클라이언트·서버간 호출)은 그대로 통과.
66
+ // Origin이 있으면 ALLOWED_ORIGINS 화이트리스트에 있어야 한다.
67
+ // CORS_ORIGIN을 명시적으로 설정한 경우 그 값도 허용 목록으로 취급한다.
68
+ const corsOriginConfigured = process.env.CORS_ORIGIN !== undefined;
69
+ const corsOrigin = process.env.CORS_ORIGIN || "*";
70
+ const allowedOrigins = (process.env.ALLOWED_ORIGINS || "")
71
+ .split(",")
72
+ .map(o => o.trim())
73
+ .filter(Boolean);
74
+ if (!corsOriginConfigured && allowedOrigins.length === 0) {
75
+ console.error("ℹ️ ALLOWED_ORIGINS 미설정 — Origin 헤더가 붙은 브라우저 요청은 차단됩니다 (DNS rebinding 방어). 웹 클라이언트를 쓰려면 ALLOWED_ORIGINS를 설정하세요.");
76
+ }
77
+ /** 이 Origin을 허용할지 판정. 반환값은 응답에 실을 ACAO 값(null이면 거부) */
78
+ function resolveOrigin(origin) {
79
+ if (!origin)
80
+ return corsOrigin; // 브라우저가 아닌 클라이언트
81
+ if (allowedOrigins.includes(origin))
82
+ return origin;
83
+ if (allowedOrigins.length === 0 && corsOriginConfigured) {
84
+ if (corsOrigin === "*")
85
+ return "*";
86
+ if (corsOrigin === origin)
87
+ return origin;
88
+ }
89
+ return null;
90
+ }
91
+ app.use((req, res, next) => {
92
+ if (req.path === "/health" || req.path === "/")
93
+ return next();
94
+ const origin = req.headers.origin;
95
+ const allowed = resolveOrigin(origin);
96
+ if (allowed === null) {
97
+ res.status(403).json({
98
+ jsonrpc: "2.0",
99
+ error: { code: -32000, message: "Origin not allowed." },
100
+ id: null,
101
+ });
102
+ return;
103
+ }
104
+ res.header("Access-Control-Allow-Origin", allowed);
105
+ if (allowed !== "*")
106
+ res.header("Vary", "Origin");
107
+ next();
108
+ });
109
+ // ── 접근 인증 (MCP_AUTH_TOKEN 설정 시에만 활성) ────────────────────────────
110
+ // 폐쇄망·사내망 배포처럼 서버 자체에 접근 통제가 필요한 환경에서 설정한다.
111
+ // 미설정이면 기존처럼 공개 동작 (법제처 API 키는 인가 수단이 아니다).
112
+ const authToken = process.env.MCP_AUTH_TOKEN || "";
113
+ if (!authToken) {
114
+ console.error("⚠️ MCP_AUTH_TOKEN 미설정 — /mcp 엔드포인트에 접근 인증이 없습니다. 폐쇄망/사내 배포에서는 반드시 설정하세요.");
115
+ }
116
+ app.use((req, res, next) => {
117
+ if (!authToken)
118
+ return next();
119
+ if (req.method === "OPTIONS")
120
+ return next(); // 프리플라이트는 인증 헤더를 못 싣는다
121
+ if (req.path === "/health" || req.path === "/")
122
+ return next();
123
+ const presented = req.headers["x-mcp-token"] ||
124
+ bearerValue(req.headers["authorization"]);
125
+ if (!presented || !safeEqual(presented, authToken)) {
126
+ res.status(401).json({
127
+ jsonrpc: "2.0",
128
+ error: { code: -32001, message: "Unauthorized." },
129
+ id: null,
130
+ });
131
+ return;
132
+ }
133
+ next();
134
+ });
48
135
  app.use(express.json({ limit: process.env.MCP_BODY_LIMIT || "100kb" }));
49
136
  // Rate Limiting (RATE_LIMIT_RPM 환경변수, 기본: 60 req/min per IP)
50
137
  const rateLimitRpm = parseInt(process.env.RATE_LIMIT_RPM || "60", 10);
@@ -93,15 +180,10 @@ export async function startHTTPServer(createServer, port) {
93
180
  }
94
181
  }, 5 * 60 * 1000).unref();
95
182
  }
96
- // CORS 및 보안 헤더 설정 (CORS_ORIGIN 미설정 시 경고)
97
- const corsOrigin = process.env.CORS_ORIGIN || "*";
98
- if (corsOrigin === "*") {
99
- console.error("⚠️ CORS_ORIGIN 미설정 — 모든 도메인 허용 중. 프로덕션에서는 CORS_ORIGIN 환경변수를 설정하세요.");
100
- }
183
+ // 보안 헤더 (Access-Control-Allow-Origin은 위 Origin 검증 미들웨어가 설정)
101
184
  app.use((req, res, next) => {
102
- res.header("Access-Control-Allow-Origin", corsOrigin);
103
185
  res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
104
- res.header("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, last-event-id");
186
+ res.header("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, last-event-id, apikey, x-api-key, x-mcp-token, authorization");
105
187
  res.header("X-Content-Type-Options", "nosniff");
106
188
  res.header("X-Frame-Options", "DENY");
107
189
  res.header("Referrer-Policy", "strict-origin-when-cross-origin");
@@ -152,14 +234,19 @@ export async function startHTTPServer(createServer, port) {
152
234
  // POST /mcp - stateless 요청 처리
153
235
  app.post("/mcp", async (req, res) => {
154
236
  // Extract API key: header > URL query
155
- // 쿼리스트링 키는 프록시/엣지 액세스 로그에 평문으로 남으므로 헤더 사용 권장 (하위호환용 유지)
237
+ // 쿼리스트링 키는 프록시/엣지 액세스 로그에 평문으로 남으므로 헤더 사용 권장.
238
+ // ALLOW_QUERY_API_KEY=0 으로 쿼리 경로를 차단할 수 있다 (폐쇄망 권장).
239
+ // 인증이 켜진 경우 Authorization 헤더는 접근 토큰이므로 법제처 키로 오인하면 안 된다.
240
+ const authHeader = bearerValue(req.headers["authorization"]);
241
+ const authHeaderIsAccessToken = Boolean(authToken) && authHeader && safeEqual(authHeader, authToken);
242
+ const queryKey = process.env.ALLOW_QUERY_API_KEY === "0" ? undefined : req.query.oc;
156
243
  const apiKey = req.headers["apikey"] ||
157
244
  req.headers["law_oc"] ||
158
245
  req.headers["law-oc"] ||
159
246
  req.headers["x-api-key"] ||
160
- req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ||
247
+ (authHeaderIsAccessToken ? undefined : authHeader || undefined) ||
161
248
  req.headers["x-law-oc"] ||
162
- req.query.oc;
249
+ queryKey;
163
250
  // 자체 키 없는 요청은 서버 LAW_OC로 폴백 — 전역 상한 적용
164
251
  // initialize/tools/list 등 핸드셰이크는 법제처 쿼터를 안 쓰므로 tools/call만 게이트
165
252
  // (핸드셰이크까지 429로 막으면 claude.ai 커넥터가 도구 목록 자체를 못 싣는다)
@@ -230,6 +317,23 @@ export async function startHTTPServer(createServer, port) {
230
317
  id: null
231
318
  });
232
319
  });
320
+ // 최종 에러 핸들러 — Express 기본 핸들러는 스택 트레이스와 설치 경로를 그대로
321
+ // 본문에 실어 보낸다(NODE_ENV 미설정 시). body-parser의 413/400도 여기로 온다.
322
+ app.use((err, _req, res, _next) => {
323
+ const status = typeof err?.status === "number" ? err.status : 500;
324
+ const scrubbed = scrubError(err);
325
+ console.error(`[express] ${status} ${scrubbed.message}`);
326
+ if (res.headersSent)
327
+ return;
328
+ res.status(status).json({
329
+ jsonrpc: "2.0",
330
+ error: {
331
+ code: status === 413 ? -32600 : -32603,
332
+ message: status === 413 ? "Request entity too large." : "Internal server error",
333
+ },
334
+ id: null,
335
+ });
336
+ });
233
337
  // 서버 시작 (0.0.0.0으로 바인딩하여 외부 접속 허용)
234
338
  const expressServer = app.listen(port, "0.0.0.0", () => {
235
339
  console.error(`✓ Korean Law MCP server (HTTP stateless) listening on port ${port}`);
package/build/setup.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * API 키를 입력받고, 선택한 AI 클라이언트 설정 파일에 MCP 서버를 자동 등록합니다.
6
6
  */
7
7
  import { createInterface } from "node:readline/promises";
8
- import { readFile, writeFile, mkdir } from "node:fs/promises";
8
+ import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
9
9
  import { existsSync } from "node:fs";
10
10
  import { resolve, dirname } from "node:path";
11
11
  import { homedir, platform } from "node:os";
@@ -87,7 +87,13 @@ async function writeJsonFile(path, data) {
87
87
  if (!existsSync(dir)) {
88
88
  await mkdir(dir, { recursive: true });
89
89
  }
90
- await writeFile(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
90
+ // 설정 파일에는 LAW_OC가 평문으로 들어간다 → 소유자만 읽도록 제한.
91
+ // mode 옵션은 새로 만들 때만 먹으므로, 기존 파일에는 chmod로 다시 적용한다.
92
+ await writeFile(path, JSON.stringify(data, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
93
+ try {
94
+ await chmod(path, 0o600);
95
+ }
96
+ catch { /* 권한 변경 실패는 설치를 막지 않는다 (Windows 등) */ }
91
97
  }
92
98
  function buildServerEntry(apiKey, lawApiProtocol = getLawApiProtocol()) {
93
99
  const env = {};
@@ -7,6 +7,7 @@ import { parseAnnexFile } from "../lib/annex-file-parser.js";
7
7
  import { truncateResponse, MAX_RESPONSE_SIZE } from "../lib/schemas.js";
8
8
  import { formatToolError, notFoundResponse } from "../lib/errors.js";
9
9
  import { getLawSiteBaseUrl } from "../lib/law-url-config.js";
10
+ import { fetchLawAnnexUnits, pickAnnexUnit } from "../lib/annex-canonical.js";
10
11
  const LAW_BASE_URL = getLawSiteBaseUrl();
11
12
  export const GetAnnexesSchema = z.object({
12
13
  lawName: z.string().describe("법령명 (예: '관세법'). 별표를 바로 지정하려면 '... 별표4' 또는 '... 별표1의2'처럼 함께 입력 가능"),
@@ -113,7 +114,7 @@ export async function getAnnexes(apiClient, input) {
113
114
  const filtered = filterByRelatedLawName(annexList, normalizedLawName);
114
115
  // 별표 선택값 지정 시 → 해당 별표 파일 다운로드 + 텍스트 추출
115
116
  if (annexSelector) {
116
- return await extractAnnexContent(filtered, annexSelector, normalizedLawName, input.knd);
117
+ return await extractAnnexContent(apiClient, filtered, annexSelector, normalizedLawName, lawType, input);
117
118
  }
118
119
  // 별표 선택값 미지정 → 기존 목록 반환
119
120
  return formatAnnexList(filtered, lawType, input, normalizedLawName);
@@ -123,9 +124,39 @@ export async function getAnnexes(apiClient, input) {
123
124
  }
124
125
  }
125
126
  // ─── 별표 텍스트 추출 ─────────────────────────────────
126
- async function extractAnnexContent(annexList, annexSelector, normalizedLawName, knd) {
127
+ async function extractAnnexContent(apiClient, annexList, annexSelector, normalizedLawName, lawType, input) {
128
+ const knd = input.knd;
127
129
  // bylSeq / annexNo / lawName 내 힌트로 유연 매칭 (별표/서식 구분 위해 knd 전달)
128
- const matched = findMatchingAnnex(annexList, annexSelector, knd);
130
+ let matched = findMatchingAnnex(annexList, annexSelector, knd);
131
+ // 법령은 현행 본문(lawService)의 별표단위 링크를 정본으로 우선 사용 (#77 — licbyl
132
+ // 인덱스가 구본/결함 파일을 가리키거나 신설 별표를 누락하는 사례). 실패 시 licbyl 폴백.
133
+ if (lawType === "law") {
134
+ const mst = matched?.관련법령일련번호 || annexList[0]?.관련법령일련번호;
135
+ if (mst) {
136
+ try {
137
+ const units = await fetchLawAnnexUnits(apiClient, String(mst), input.apiKey);
138
+ const unit = pickAnnexUnit(units, {
139
+ code6: matched?.별표번호 ? String(matched.별표번호).trim() : undefined,
140
+ kind: matched?.별표종류 ? String(matched.별표종류) : undefined,
141
+ selectorCandidates: matched ? undefined : buildSelectorCandidates(annexSelector),
142
+ knd,
143
+ });
144
+ if (unit) {
145
+ matched = {
146
+ ...(matched ?? {}),
147
+ 별표번호: matched?.별표번호 || unit.code6,
148
+ 별표명: matched?.별표명 || unit.title,
149
+ 별표종류: matched?.별표종류 || unit.kind,
150
+ 별표서식파일링크: unit.hwpLink || matched?.별표서식파일링크,
151
+ 별표서식PDF파일링크: unit.pdfLink || matched?.별표서식PDF파일링크,
152
+ };
153
+ }
154
+ }
155
+ catch {
156
+ // 정본 조회 실패 → licbyl 링크로 진행
157
+ }
158
+ }
159
+ }
129
160
  if (!matched) {
130
161
  const availableBylSeq = annexList.map((a) => a.별표번호).filter(Boolean).slice(0, 20).join(", ");
131
162
  return notFoundResponse(`별표 선택값 "${annexSelector}"에 해당하는 항목을 찾을 수 없습니다. (법령: ${normalizedLawName})`, [
@@ -261,10 +261,17 @@ async function fetchManualRedirect(url, proxyConfig) {
261
261
  location: response.headers.get("location"),
262
262
  };
263
263
  }
264
+ /** 판례 본문 fallback 경로에서 따라가도 되는 호스트 (법제처·국세청만) */
265
+ function isAllowedFallbackHost(hostname) {
266
+ return /(^|\.)law\.go\.kr$/i.test(hostname) || /(^|\.)nts\.go\.kr$/i.test(hostname);
267
+ }
264
268
  async function resolveTaxlawDetailUrl(iframeUrl) {
265
269
  let currentUrl = iframeUrl;
266
270
  const proxyConfig = getExternalHttpsProxyConfig();
267
271
  for (let redirectCount = 0; redirectCount < 3; redirectCount++) {
272
+ if (!isAllowedFallbackHost(new URL(currentUrl).hostname)) {
273
+ throw new Error("precedent fallback redirect target is not an allowed host");
274
+ }
268
275
  const iframeResponse = await fetchManualRedirect(currentUrl, proxyConfig);
269
276
  const location = iframeResponse.location;
270
277
  if (!location) {
@@ -272,6 +279,9 @@ async function resolveTaxlawDetailUrl(iframeUrl) {
272
279
  }
273
280
  const nextUrl = normalizeUrl(location, currentUrl);
274
281
  const parsedNextUrl = new URL(nextUrl);
282
+ if (!isAllowedFallbackHost(parsedNextUrl.hostname)) {
283
+ throw new Error("precedent fallback redirect target is not an allowed host");
284
+ }
275
285
  if (parsedNextUrl.searchParams.get("ntstDcmId")) {
276
286
  return nextUrl;
277
287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "korean-law-mcp",
3
- "version": "4.9.2",
3
+ "version": "4.9.4",
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",
@@ -60,7 +60,7 @@
60
60
  "commander": "^14.0.3",
61
61
  "dotenv": "^17.3.1",
62
62
  "express": "^5.2.1",
63
- "kordoc": "^4.2.0",
63
+ "kordoc": "^4.5.0",
64
64
  "pdfjs-dist": "^5.5.207",
65
65
  "zod": "^4.0.0"
66
66
  },
@@ -73,7 +73,10 @@
73
73
  "vitest": "^4.1.9"
74
74
  },
75
75
  "overrides": {
76
- "hono": "^4.12.29"
76
+ "hono": "^4.12.29",
77
+ "sharp": "^0.35.3",
78
+ "adm-zip": "^0.6.0",
79
+ "onnxruntime-node": "^1.27.0"
77
80
  },
78
81
  "files": [
79
82
  "build",