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.
- package/build/lib/annex-canonical.d.ts +39 -0
- package/build/lib/annex-canonical.js +78 -0
- package/build/lib/external-https-proxy.js +7 -1
- package/build/lib/law-antibot.js +7 -1
- package/build/server/http-server.js +114 -10
- package/build/setup.js +8 -2
- package/build/tools/annex.js +34 -3
- package/build/tools/precedents.js +10 -0
- package/package.json +6 -3
|
@@ -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.
|
|
33
|
+
rejectUnauthorized: !disableTls || process.env.NODE_ENV === "production",
|
|
28
34
|
proxyAuthorization,
|
|
29
35
|
};
|
|
30
36
|
}
|
package/build/lib/law-antibot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
247
|
+
(authHeaderIsAccessToken ? undefined : authHeader || undefined) ||
|
|
161
248
|
req.headers["x-law-oc"] ||
|
|
162
|
-
|
|
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
|
-
|
|
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 = {};
|
package/build/tools/annex.js
CHANGED
|
@@ -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
|
|
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,
|
|
127
|
+
async function extractAnnexContent(apiClient, annexList, annexSelector, normalizedLawName, lawType, input) {
|
|
128
|
+
const knd = input.knd;
|
|
127
129
|
// bylSeq / annexNo / lawName 내 힌트로 유연 매칭 (별표/서식 구분 위해 knd 전달)
|
|
128
|
-
|
|
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.
|
|
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.
|
|
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",
|