korean-law-mcp 4.2.1 → 4.4.0

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Korean Law MCP
2
2
 
3
- **법제처 42개 API를 17개 도구로.** 법령, 판례, 행정규칙, 자치법규, 조약, 해석례(국세청 포함) + **LLM 환각 방지 인용 검증** + **조문 영향 그래프** + **시점 비교 자동 diff** + **이럴 땐 이렇게 — 5단계 안내**를 AI 어시스턴트나 터미널에서 바로 사용.
3
+ **법제처 42개 API를 9개 도구로.** 법령, 판례, 행정규칙, 자치법규, 조약, 해석례(국세청 포함) + **LLM 환각 방지 인용 검증** + **조문 영향 그래프** + **시점 비교 자동 diff** + **이럴 땐 이렇게 — 5단계 안내** + **판례 생사 확인(Citator)** + **행위시법 판단**을 AI 어시스턴트나 터미널에서 바로 사용.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/korean-law-mcp.svg)](https://www.npmjs.com/package/korean-law-mcp)
6
6
  [![MCP 1.27](https://img.shields.io/badge/MCP-1.27-blue)](https://modelcontextprotocol.io)
@@ -14,6 +14,43 @@
14
14
 
15
15
  ---
16
16
 
17
+ ## v4.4.0 — 노출 도구 통폐합 19개 → 9개 (컨텍스트 52% 감축)
18
+
19
+ MCP 클라이언트가 매 세션 읽는 도구 목록(ListTools)을 ~15.1KB → ~7.2KB로 줄였습니다.
20
+
21
+ - `chain_*` 8개 → **`legal_research`** 하나로 (`task` 파라미터: full_research·law_system·action_basis·dispute_prep·amendment_track·ordinance_compare·procedure_detail·document_review)
22
+ - 킬러피처 4개(`verify_citations`·`cite_check`·`applicable_law`·`impact_map`) → **`legal_analysis`** 하나로 (`mode` 파라미터)
23
+ - **하위호환**: 기존 도구명 직접 호출·`execute_tool` 경유 모두 그대로 동작. 광고 목록에서만 빠짐
24
+
25
+ ## v4.3 — 판례 생사 확인 + 행위시법 판단
26
+
27
+ **"이 판례 아직 유효한가?" + "사건 시점엔 어떤 법이 적용되나?"** — 법률 실무에서 가장 위험한 두 실수를 잡는다.
28
+
29
+ ### 1. `cite_check` — 판례 생사 확인 (한국형 Shepard's Citator)
30
+
31
+ ```
32
+ "2007다27670 아직 유효해?"
33
+ ```
34
+
35
+ → 그 사건번호를 **인용한 후속 판례를 본문검색으로 역추적** + 전원합의체 후속 판결 본문 정밀 스캔 → 변경·폐기 선언 감지:
36
+
37
+ ```
38
+ 📊 판정: ❌ 변경·폐기 신호 감지 — 2018다248626(판례 변경 선언, 저촉 범위 변경)
39
+ 맥락: "…2008년 전원합의체 판결은 이 판결의 견해와 배치되는 범위에서 변경하기로 한다…"
40
+ ```
41
+
42
+ 판결문이 사건번호 대신 "(이하 '2008년 전원합의체 판결'이라 한다)" 별칭으로 변경 선언하는 관행까지 추적. 변경된 판례를 살아있는 것처럼 인용하는 사고를 차단한다. 무료 도구 중 유일.
43
+
44
+ ### 2. `applicable_law` — 행위시법 판단 + 부칙 경과규정
45
+
46
+ ```
47
+ "2023.5.10 당시 도로교통법 제44조"
48
+ ```
49
+
50
+ → 기준일에 **시행 중이던 버전(MST) 특정** → 그 시점 조문 본문 → 현행과 비교 → **이후 개정 부칙의 적용례·경과조치 자동 발췌** + 행위시법(형법 §1)·제재처분 위반행위시법(행정기본법 §14③) 법리 안내. LLM이 현행법으로 오답하는 것을 구조적으로 방지.
51
+
52
+ ---
53
+
17
54
  ## v4.0 — 3개 킬러 기능 동시 추가
18
55
 
19
56
  **조문 영향 그래프 + 시점 비교 + 단계별 안내.** 법무팀·연구자·실수요자가 매뉴얼로 며칠 걸리던 작업이 한 번에.
@@ -328,7 +365,7 @@ lexdiff에서 "산안기준규칙" 질의가 법제처 aiSearch의 키워드 부
328
365
  **v3.0.2** — Unified Architecture + Setup Wizard
329
366
 
330
367
  법제처 41개 API를 89개 MCP 도구로 구조화했던 v2.
331
- v3는 같은 41개 API를 **14개 도구**로 재압축했습니다 (v3.2.2 이후 15개, v4.0 현재 17개).
368
+ v3는 같은 41개 API를 **14개 도구**로 재압축했습니다 (v3.2.2 이후 15개, v4.3에서 19개, v4.4.0에서 통폐합으로 9개).
332
369
 
333
370
  | | 법제처 원본 | v2 | v3 |
334
371
  |---|:---:|:---:|:---:|
@@ -392,7 +429,7 @@ MCP 도구 설계에서 **도구 수 ≠ 기능 수**입니다.
392
429
 
393
430
  대한민국에는 **1,600개 이상의 현행 법률**, **10,000개 이상의 행정규칙**, 그리고 대법원·헌법재판소·조세심판원·관세청까지 이어지는 방대한 판례 체계가 있습니다. 이 모든 게 [법제처](https://www.law.go.kr)라는 하나의 사이트에 있지만, 개발자 경험은 최악입니다.
394
431
 
395
- 이 프로젝트는 그 전체 법령 시스템을 **17개 도구**로 감싸서, AI 어시스턴트나 스크립트에서 바로 호출할 수 있게 만듭니다. 법제처를 수백 번 수동 검색하다 지친 공무원이 만들었습니다.
432
+ 이 프로젝트는 그 전체 법령 시스템을 **9개 도구**로 감싸서, AI 어시스턴트나 스크립트에서 바로 호출할 수 있게 만듭니다. 법제처를 수백 번 수동 검색하다 지친 공무원이 만들었습니다.
396
433
 
397
434
  ---
398
435
 
@@ -497,7 +534,7 @@ https://korean-law-mcp.fly.dev/mcp?oc=honggildong
497
534
 
498
535
  > **참고**: 커넥터 URL을 수정하려면 삭제 후 다시 추가해야 합니다.
499
536
 
500
- > v3부터 프로필 선택이 필요 없습니다. 17개 도구가 42개 API 전체를 커버합니다.
537
+ > v3부터 프로필 선택이 필요 없습니다. 9개 도구가 42개 API 전체를 커버합니다.
501
538
  > 기존에 `?profile=lite&oc=...` 주소를 넣으셨다면 **그대로 두셔도 됩니다** — 동일하게 작동합니다.
502
539
 
503
540
  ---
@@ -719,37 +756,51 @@ reg delete "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /
719
756
 
720
757
  ---
721
758
 
722
- ## 도구 구조 (17개)
759
+ ## 도구 구조 (9개)
723
760
 
724
- v4 17개 도구만 노출합니다. 나머지 전문 도구는 `discover_tools` → `execute_tool`로 접근.
761
+ v4.4.0은 9개 도구만 노출합니다 (컨텍스트 52% 감축). 기존 `chain_*` 8개는 `legal_research`의 `task`로, 킬러피처 4개는 `legal_analysis`의 `mode`로 통합. 나머지 전문 도구는 `discover_tools` → `execute_tool`로 접근하며, 기존 도구명 직접 호출도 하위호환으로 계속 동작합니다.
725
762
 
726
- | 구분 | 도구 | 설명 | 시나리오 확장 |
727
- |------|------|------|-------------|
728
- | **체인** (8) | `chain_full_research` | 종합 리서치 (AI검색→법령→판례→해석) | `customs`: 관세·통관 종합 |
729
- | | `chain_law_system` | 법체계 분석 (3단비교, 위임구조) | `delegation`: 위임입법 감시 / `impact`: 영향도 분석 |
730
- | | `chain_action_basis` | 처분 근거 확인 (허가·인가·처분) | `penalty`: 처분·벌칙 기준 종합 / `action_plan`: 이럴 땐 이렇게, 5단계 안내 |
731
- | | `chain_dispute_prep` | 쟁송 대비 (불복·소송·심판) | — |
732
- | | `chain_amendment_track` | 개정 추적 (신구대조, 연혁) | `timeline`: 시계열 타임라인 / `time_travel`: 두 시점 자동 diff |
733
- | | `chain_ordinance_compare` | 조례 비교 (상위법→전국 조례) | `compliance`: 상위법 적합성 검증 |
734
- | | `chain_procedure_detail` | 절차·비용·서식 안내 | `manual`: 공무원 처리 매뉴얼 |
735
- | | `chain_document_review` | 계약서·약관 리스크 분석 | — |
763
+ | 구분 | 도구 | 설명 |
764
+ |------|------|------|
765
+ | **리서치** (1) | `legal_research` | 다단계 법령 리서치 `task` 8종 선택 (아래 표) |
766
+ | **정밀분석** (1) | `legal_analysis` | 검증·분석 `mode` 4종 선택 (아래 표) |
736
767
  | **법령** (3) | `search_law` | 법령 검색 → lawId, MST 획득 |
737
768
  | | `get_law_text` | 조문 전문 조회 |
738
769
  | | `get_annexes` | 별표/서식 조회 (금액표·요율표·별지서식) |
739
770
  | **통합** (2) | `search_decisions` | **17개 도메인** 통합 검색 (판례·헌재·조세심판·공정위·노동위·관세·해석례·행심·개인정보위·권익위·소청심사·학칙·공사공단·공공기관·조약·영문법령) |
740
771
  | | `get_decision_text` | **17개 도메인** 전문 조회 |
741
- | **킬러** (2) | `verify_citations` | LLM 환각 방지 — 인용 조문 실존 여부 일괄 검증 (v3.5) |
742
- | | `impact_map` | 조문 영향 그래프 — 인용 판례·해석·자치법규 역방향 탐색 + mermaid (v4.0) |
743
772
  | **메타** (2) | `discover_tools` | 전문 도구 검색 (용어·별표·이력·비교 등) |
744
773
  | | `execute_tool` | 전문 도구 프록시 실행 |
745
774
 
775
+ ### `legal_research` task 8종 (구 chain_*)
776
+
777
+ | task | 설명 | 시나리오 확장 |
778
+ |------|------|-------------|
779
+ | `full_research` (기본) | 종합 리서치 (AI검색→법령→판례→해석) | `customs`: 관세·통관 종합 / `action_plan`: 이럴 땐 이렇게, 5단계 안내 |
780
+ | `law_system` | 법체계 분석 (3단비교, 위임구조) | `delegation`: 위임입법 감시 / `impact`: 영향도 분석 |
781
+ | `action_basis` | 처분 근거 확인 (허가·인가·처분) | `penalty`: 처분·벌칙 기준 종합 |
782
+ | `dispute_prep` | 쟁송 대비 (불복·소송·심판) | `domain`: tax/labor/privacy/competition |
783
+ | `amendment_track` | 개정 추적 (신구대조, 연혁) | `timeline`: 시계열 타임라인 / `time_travel`: 두 시점 자동 diff |
784
+ | `ordinance_compare` | 조례 비교 (상위법→전국 조례) | `compliance`: 상위법 적합성 검증 |
785
+ | `procedure_detail` | 절차·비용·서식 안내 | `manual`: 공무원 처리 매뉴얼 |
786
+ | `document_review` | 계약서·약관 리스크 분석 (`text` 필수) | — |
787
+
788
+ ### `legal_analysis` mode 4종 (구 킬러피처)
789
+
790
+ | mode | 설명 | 필수 파라미터 |
791
+ |------|------|-------------|
792
+ | `verify_citations` | LLM 환각 방지 — 인용 조문 실존 여부 일괄 검증 (v3.5) | `text` |
793
+ | `cite_check` | 판례 생사 확인 — 후속 인용 역추적 + 변경·폐기 감지, 한국형 Citator (v4.3) | `caseNumber` |
794
+ | `applicable_law` | 행위시법 판단 — 시점 적용 버전 + 부칙 경과규정 발췌 (v4.3) | `lawName`, `date` |
795
+ | `impact_map` | 조문 영향 그래프 — 인용 판례·해석·자치법규 역방향 탐색 + mermaid (v4.0) | `lawName`, `jo` |
796
+
746
797
  전체 도구 상세는 [docs/API.md](docs/API.md) 참조.
747
798
 
748
799
  ---
749
800
 
750
801
  ## 주요 특징
751
802
 
752
- - **42개 API → 17개 도구** — 법령, 판례, 행정규칙, 자치법규, 헌재결정, 조세심판, 관세해석, 국세청 해석례, 조약, 학칙/공단/공공기관 규정, 법령용어
803
+ - **42개 API → 9개 도구** — 법령, 판례, 행정규칙, 자치법규, 헌재결정, 조세심판, 관세해석, 국세청 해석례, 조약, 학칙/공단/공공기관 규정, 법령용어
753
804
  - **MCP + CLI** — Claude Desktop에서도, 터미널에서도 같은 도구 사용
754
805
  - **법률 도메인 특화** — 약칭 자동 인식(`화관법` → `화학물질관리법`), 조문번호 변환(`제38조` ↔ `003800`), 3단 위임 구조 시각화
755
806
  - **별표/별지서식 본문 추출** — HWPX·HWP·PDF·XLSX·DOCX 자동 변환 ([kordoc](https://github.com/chrisryugj/kordoc) 엔진)
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * 통일된 에러 처리 모듈
3
3
  */
4
+ import { maskSensitiveUrl } from "./fetch-with-retry.js";
4
5
  /**
5
6
  * 에러 코드
6
7
  */
@@ -114,7 +115,8 @@ export function formatToolError(error, context) {
114
115
  suggestions = [];
115
116
  }
116
117
  const lines = [];
117
- lines.push(`[${code}] ${msg}`);
118
+ // 최종 방어선 — 도구 코드가 URL 포함 에러를 직접 만들어도 API 키가 클라이언트로 새지 않게
119
+ lines.push(`[${code}] ${maskSensitiveUrl(msg)}`);
118
120
  if (context) {
119
121
  lines.push(`도구: ${context}`);
120
122
  }
@@ -64,45 +64,43 @@ export async function findLaws(apiClient, query, apiKey, max = 3, searchDisplay
64
64
  if (cached)
65
65
  return cached.slice(0, max);
66
66
  const effectiveMax = Math.max(max, searchDisplay); // 정렬 대상 전체 수집
67
+ // 인프라 에러(타임아웃·5xx·파싱 실패)는 "법령 없음"과 구분해야 한다.
68
+ // 삼키면 법제처 장애 중 verify_citations가 실존 조문을 NOT_FOUND로 오판한다.
69
+ let lastInfraError;
70
+ const trySearch = async (q) => {
71
+ try {
72
+ const xmlText = await apiClient.searchLaw(q, apiKey, searchDisplay);
73
+ return parseLawXml(xmlText, effectiveMax);
74
+ }
75
+ catch (e) {
76
+ if (e instanceof Error && /429|401|403|API 키/.test(e.message))
77
+ throw e;
78
+ lastInfraError = e;
79
+ return [];
80
+ }
81
+ };
67
82
  // 1차: 원본 쿼리
68
- let results = [];
69
- try {
70
- const xmlText = await apiClient.searchLaw(query, apiKey, searchDisplay);
71
- results = parseLawXml(xmlText, effectiveMax);
72
- }
73
- catch (e) {
74
- if (e instanceof Error && /429|401|403|API 키/.test(e.message))
75
- throw e;
76
- }
83
+ let results = await trySearch(query);
77
84
  // 2차: 부가 키워드 제거
78
85
  if (results.length === 0) {
79
86
  const stripped = stripNonLawKeywords(query);
80
87
  if (stripped && stripped !== query) {
81
- try {
82
- const xmlText = await apiClient.searchLaw(stripped, apiKey, searchDisplay);
83
- results = parseLawXml(xmlText, effectiveMax);
84
- }
85
- catch (e) {
86
- if (e instanceof Error && /429|401|403|API 키/.test(e.message))
87
- throw e;
88
- }
88
+ results = await trySearch(stripped);
89
89
  }
90
90
  }
91
91
  // 3차: 법령명 패턴 직접 추출
92
92
  if (results.length === 0) {
93
93
  const lawNameMatch = query.match(/[가-힣]+(법|시행령|시행규칙|규칙|규정|령)(?:\s|$)/);
94
94
  if (lawNameMatch) {
95
- const extracted = lawNameMatch[0].trim();
96
- try {
97
- const xmlText = await apiClient.searchLaw(extracted, apiKey, searchDisplay);
98
- results = parseLawXml(xmlText, effectiveMax);
99
- }
100
- catch (e) {
101
- if (e instanceof Error && /429|401|403|API 키/.test(e.message))
102
- throw e;
103
- }
95
+ results = await trySearch(lawNameMatch[0].trim());
104
96
  }
105
97
  }
98
+ // 전 단계가 인프라 에러로만 끝났으면 "없음"이 아니라 "실패"로 전파
99
+ if (results.length === 0 && lastInfraError !== undefined) {
100
+ throw lastInfraError instanceof Error
101
+ ? new Error(`법령 검색 실패 (법제처 API 오류 — 법령이 없다는 뜻이 아님): ${lastInfraError.message}`)
102
+ : lastInfraError;
103
+ }
106
104
  // 관련도 정렬
107
105
  if (results.length > 1) {
108
106
  const queryWords = query.replace(NON_LAW_NAME_RE, " ")
@@ -29,15 +29,16 @@ function extractLawName(query) {
29
29
  // 수식어: 단독 키워드만 제거 (법령명 일부인 경우 보존)
30
30
  // "별표 1", "별표" 등 독립적 사용만 제거
31
31
  .replace(/별표\s*\d*/g, "")
32
- .replace(/(?:^|\s)(판례|판결|사례|대법원|헌재|행정심판)(?:\s|$)/g, " ")
33
- .replace(/(?:^|\s)(해석례?|유권해석|질의회신)(?:\s|$)/g, " ")
34
- .replace(/(?:^|\s)(개정|이력|변경|연혁|신구대조)(?:\s|$)/g, " ")
35
- .replace(/(?:^|\s)(3단비교|위임|인용|체계)(?:\s|$)/g, " ")
36
- .replace(/(?:^|\s)(영문|영어|English)(?:\s|$)/gi, " ")
37
- .replace(/(?:^|\s)(서식|양식|별지|신청서)(?:\s|$)/g, " ")
32
+ // 뒤 경계는 lookahead(소비 안 함) — 소비하면 "개정 연혁"처럼 연속 키워드의 두 번째가 살아남음
33
+ .replace(/(?:^|\s)(판례|판결|사례|대법원|헌재|행정심판)(?=\s|$)/g, " ")
34
+ .replace(/(?:^|\s)(해석례?|유권해석|질의회신)(?=\s|$)/g, " ")
35
+ .replace(/(?:^|\s)(개정|이력|변경|연혁|신구대조)(?=\s|$)/g, " ")
36
+ .replace(/(?:^|\s)(3단비교|위임|인용|체계)(?=\s|$)/g, " ")
37
+ .replace(/(?:^|\s)(영문|영어|English)(?=\s|$)/gi, " ")
38
+ .replace(/(?:^|\s)(서식|양식|별지|신청서)(?=\s|$)/g, " ")
38
39
  // 조례/규칙은 법령명 일부이므로 유지
39
40
  // 동사형 수식어 제거
40
- .replace(/(?:^|\s)(검색|조회|확인|알려줘|찾아줘|보여줘)(?:\s|$)/g, " ")
41
+ .replace(/(?:^|\s)(검색|조회|확인|알려줘|찾아줘|보여줘)(?=\s|$)/g, " ")
41
42
  // 정리
42
43
  .replace(/\s+/g, " ")
43
44
  .trim();
@@ -70,6 +71,11 @@ const routePatterns = [
70
71
  if (/(?:파급|영향\s*그래프|impact|인용한\s*(?:모든|판례|판결|어디))/i.test(query)) {
71
72
  return { _skip: true };
72
73
  }
74
+ // applicable_law 양보: 기준일 + 시점 키워드 (예: "2023.5.10 당시 도로교통법 제44조")
75
+ if (/행위시법/.test(query) ||
76
+ (/\d{4}\s*[년.\-/]\s*\d{1,2}/.test(query) && /당시|시점|기준|에\s*적용/.test(query))) {
77
+ return { _skip: true };
78
+ }
73
79
  const jo = extractArticleNumber(query);
74
80
  const lawName = extractLawName(query);
75
81
  return { _searchQuery: lawName, jo, _needsMst: true };
@@ -592,6 +598,55 @@ const routePatterns = [
592
598
  reason: "시민 상황 키워드 → chain_full_research (action_plan 5단계 가이드)",
593
599
  priority: 7,
594
600
  },
601
+ // ── 29-0-3. 판례 생사 확인 (cite_check, v4.3) ──
602
+ // "2013다61381 아직 유효해?", "이 판례 변경됐어?", "2018두42559 인용 추적"
603
+ {
604
+ name: "cite_check",
605
+ patterns: [
606
+ /\d{2,4}\s*[가-힣]{1,5}\s*\d{1,7}.*?(?:유효|살아|변경|폐기|뒤집|생사|추적|아직|citator)/i,
607
+ /판례\s*(?:생사|유효성|변경\s*여부|폐기\s*여부|인용\s*추적)/,
608
+ /(?:변경|폐기)된?\s*판례(?:인지|냐|인가요?|\s*확인)/,
609
+ ],
610
+ tool: "cite_check",
611
+ extract: (query) => {
612
+ const m = query.match(/(\d{2,4})\s*([가-힣]{1,5})\s*(\d{1,7})/);
613
+ if (!m)
614
+ return { _fallback: true, query };
615
+ return { caseNumber: `${m[1]}${m[2]}${m[3]}` };
616
+ },
617
+ reason: "사건번호 + 유효성 키워드 → cite_check (후속 인용 역추적 + 변경·폐기 감지)",
618
+ priority: 2,
619
+ },
620
+ // ── 29-0-4. 행위시법 판단 (applicable_law, v4.3) ──
621
+ // "2023년 5월 당시 도로교통법 제44조", "사건 시점에 적용되는 법", "행위시법"
622
+ {
623
+ name: "applicable_law",
624
+ patterns: [
625
+ /(\d{4})\s*[년\.\-\/]\s*(\d{1,2})\s*[월\.\-\/]?\s*(\d{1,2})?\s*일?\s*(?:당시|시점|기준|에\s*적용)/,
626
+ /(?:행위\s*시|사건\s*당시|계약\s*당시|위반\s*당시)\s*(?:의\s*)?(?:법|적용)/,
627
+ /행위시법|적용\s*법령\s*판단|당시\s*시행/,
628
+ ],
629
+ tool: "applicable_law",
630
+ extract: (query) => {
631
+ const dm = query.match(/(\d{4})\s*[년\.\-\/]\s*(\d{1,2})(?:\s*[월\.\-\/]\s*(\d{1,2}))?/);
632
+ if (!dm)
633
+ return { _fallback: true, query };
634
+ const date = `${dm[1]}${dm[2].padStart(2, "0")}${(dm[3] || "1").padStart(2, "0")}`;
635
+ const jo = extractArticleNumber(query);
636
+ // 키워드 strip은 단어 경계 필수 — "근로기준법"의 "기준"을 떼면 법령명 파괴
637
+ const lawName = extractLawName(query.replace(/(\d{4})\s*[년\.\-\/]\s*\d{1,2}\s*[월\.\-\/]?\s*\d{0,2}\s*일?/g, " ")
638
+ .replace(/(?:^|\s)(당시|시점|기준일?|행위시법|사건|적용|시행)(?=\s|$)/g, " ")
639
+ .replace(/에\s*적용되?는?\s*법령?/g, " "));
640
+ if (!lawName)
641
+ return { _fallback: true, query };
642
+ const params = { lawName, date };
643
+ if (jo)
644
+ params.jo = jo;
645
+ return params;
646
+ },
647
+ reason: "기준일 + 법령명 → applicable_law (시점 적용 버전 + 부칙 경과규정)",
648
+ priority: 2,
649
+ },
595
650
  // ── 29-1. 인용 검증 (citation validator) ──
596
651
  {
597
652
  name: "verify_citations",
@@ -709,8 +764,10 @@ export function routeQuery(query) {
709
764
  // 자연어 날짜 조건 추출 (검색어에서 시간 표현 분리)
710
765
  const dateParsed = parseDateRange(q);
711
766
  const dateRange = dateParsed.range;
767
+ // 행위시법(applicable_law) 의도는 날짜 자체가 파라미터 — 날짜 제거 전 원문으로 매칭해야 함
768
+ const applicableLawHint = /행위시법|\d{4}\s*[년.\-/].{0,14}(?:당시|시점|기준|에\s*적용)/.test(q);
712
769
  // 날짜 표현이 제거된 순수 검색어로 패턴 매칭
713
- const routeInput = dateParsed.cleanQuery || q;
770
+ const routeInput = applicableLawHint ? q : (dateParsed.cleanQuery || q);
714
771
  const result = _matchRoute(routeInput);
715
772
  // 날짜 범위가 있으면 결과에 첨부
716
773
  if (dateRange) {
@@ -35,6 +35,8 @@ export const TOOL_ALIASES = {
35
35
  "해석례": ["법제처 해석", "유권해석", "질의회신"],
36
36
  // 도구 의도 별칭
37
37
  "인용검증": ["verify_citations", "조문 실존 확인", "환각 검증"],
38
+ "판례생사": ["cite_check", "판례 유효성", "판례 변경 여부", "인용 추적", "citator"],
39
+ "행위시법": ["applicable_law", "당시 법령", "적용 법령 판단", "경과조치", "부칙"],
38
40
  "문서검토": ["analyze_document", "chain_document_review", "계약서 검토", "약관 검토"],
39
41
  "처분기준": ["chain_action_basis", "과태료 기준", "과징금 기준", "영업정지 기간"],
40
42
  "절차매뉴얼": ["chain_procedure_detail", "처리 절차", "신청 방법", "수수료"],
@@ -70,5 +72,7 @@ export const TOOL_CATEGORIES = {
70
72
  "영문법령": ["search_english_law", "get_english_law_text"],
71
73
  "용어": ["search_legal_terms", "get_legal_term_kb", "get_legal_term_detail", "get_daily_term", "get_daily_to_legal", "get_legal_to_daily", "get_term_articles", "get_related_laws"],
72
74
  "문서분석": ["analyze_document"],
75
+ "판례생사": ["cite_check"],
76
+ "행위시법": ["applicable_law"],
73
77
  "유틸리티": ["parse_jo_code", "get_law_abbreviations"],
74
78
  };
@@ -7,6 +7,11 @@
7
7
  * 예: <strong class="tbl_tx_type">지방</strong>자치법 → 지방자치법
8
8
  */
9
9
  export declare function stripHtml(text: string): string;
10
+ /**
11
+ * 단일 객체 정규화 (Critical Rule 6) — API 응답의 배열 필드가 단일 객체로 올 수 있음.
12
+ * 기존 수동 패턴 `Array.isArray(x) ? x : x ? [x] : []`과 의미 동일 (falsy → []).
13
+ */
14
+ export declare function toArray<T>(x: T | T[] | null | undefined): T[];
10
15
  /**
11
16
  * XML 태그에서 텍스트 추출 (CDATA 지원)
12
17
  */
@@ -9,6 +9,13 @@
9
9
  export function stripHtml(text) {
10
10
  return text.replace(/<[^>]+>/g, "");
11
11
  }
12
+ /**
13
+ * 단일 객체 정규화 (Critical Rule 6) — API 응답의 배열 필드가 단일 객체로 올 수 있음.
14
+ * 기존 수동 패턴 `Array.isArray(x) ? x : x ? [x] : []`과 의미 동일 (falsy → []).
15
+ */
16
+ export function toArray(x) {
17
+ return Array.isArray(x) ? x : x ? [x] : [];
18
+ }
12
19
  /**
13
20
  * XML 태그에서 텍스트 추출 (CDATA 지원)
14
21
  */
@@ -9,6 +9,7 @@ import express from "express";
9
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
10
10
  import { requestContext } from "../lib/session-state.js";
11
11
  import { maskSensitiveUrl } from "../lib/fetch-with-retry.js";
12
+ import { TOOL_COUNTS } from "../tool-registry.js";
12
13
  import { VERSION } from "../version.js";
13
14
  /**
14
15
  * 에러 메시지에서 민감 정보(API 키 포함 URL) scrub.
@@ -97,26 +98,49 @@ export async function startHTTPServer(createServer, port) {
97
98
  health: "/health",
98
99
  },
99
100
  tools: {
100
- exposed: 16,
101
- total: 92,
102
- description: "V3_EXPOSED 16개 직노출, 나머지 76개는 execute_tool 경유",
101
+ exposed: TOOL_COUNTS.exposed,
102
+ total: TOOL_COUNTS.total,
103
+ description: `V3_EXPOSED ${TOOL_COUNTS.exposed}개 직노출, 나머지 ${TOOL_COUNTS.total - TOOL_COUNTS.exposed}개는 execute_tool 경유`,
103
104
  },
104
105
  });
105
106
  });
106
107
  app.get("/health", (req, res) => {
107
108
  res.json({ status: "ok", timestamp: new Date().toISOString() });
108
109
  });
110
+ // 서버 LAW_OC 폴백 사용량 전역 상한 — 키 없는 분산 요청이 서버 키의 법제처 quota를
111
+ // 소진시키는 것 방지 (IP당 limit만으로는 막을 수 없음). 0이면 폴백 비활성.
112
+ const fallbackRpm = parseInt(process.env.FALLBACK_RATE_LIMIT_RPM || "120", 10);
113
+ const fallbackBucket = { count: 0, resetAt: 0 };
114
+ function fallbackAllowed() {
115
+ if (fallbackRpm <= 0)
116
+ return false;
117
+ const now = Date.now();
118
+ if (now >= fallbackBucket.resetAt) {
119
+ fallbackBucket.count = 0;
120
+ fallbackBucket.resetAt = now + 60000;
121
+ }
122
+ return ++fallbackBucket.count <= fallbackRpm;
123
+ }
109
124
  // POST /mcp - stateless 요청 처리
110
125
  app.post("/mcp", async (req, res) => {
111
- // Extract API key: URL query > header
112
- const apiKeyFromQuery = req.query.oc;
113
- const apiKey = apiKeyFromQuery ||
114
- req.headers["apikey"] ||
126
+ // Extract API key: header > URL query
127
+ // 쿼리스트링 키는 프록시/엣지 액세스 로그에 평문으로 남으므로 헤더 사용 권장 (하위호환용 유지)
128
+ const apiKey = req.headers["apikey"] ||
115
129
  req.headers["law_oc"] ||
116
130
  req.headers["law-oc"] ||
117
131
  req.headers["x-api-key"] ||
118
132
  req.headers["authorization"]?.replace(/^Bearer\s+/i, "") ||
119
- req.headers["x-law-oc"];
133
+ req.headers["x-law-oc"] ||
134
+ req.query.oc;
135
+ // 자체 키 없는 요청은 서버 LAW_OC로 폴백 — 전역 상한 적용
136
+ if (!apiKey && !fallbackAllowed()) {
137
+ res.status(429).json({
138
+ jsonrpc: "2.0",
139
+ error: { code: -32000, message: "Shared API quota exceeded. Provide your own key via 'apiKey' header (free: https://open.law.go.kr)." },
140
+ id: null,
141
+ });
142
+ return;
143
+ }
120
144
  let server;
121
145
  let transport;
122
146
  try {
@@ -180,12 +204,19 @@ export async function startHTTPServer(createServer, port) {
180
204
  console.error(`✓ MCP endpoint: http://0.0.0.0:${port}/mcp`);
181
205
  console.error(`✓ Health check: http://0.0.0.0:${port}/health`);
182
206
  });
183
- // 종료 처리
184
- async function gracefulShutdown(signal) {
207
+ // 종료 처리 — in-flight 요청 완료 대기 (최대 10초), 이후 강제 종료
208
+ function gracefulShutdown(signal) {
185
209
  console.error(`${signal} received, shutting down server...`);
186
- expressServer.close();
187
- console.error("Server shutdown complete");
188
- process.exit(0);
210
+ const forceExit = setTimeout(() => {
211
+ console.error("Shutdown timeout (10s) — forcing exit");
212
+ process.exit(1);
213
+ }, 10000);
214
+ forceExit.unref();
215
+ expressServer.close(() => {
216
+ clearTimeout(forceExit);
217
+ console.error("Server shutdown complete");
218
+ process.exit(0);
219
+ });
189
220
  }
190
221
  process.on("SIGINT", () => gracefulShutdown("SIGINT"));
191
222
  process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
@@ -9,4 +9,9 @@ import type { McpTool } from "./lib/types.js";
9
9
  * 모든 MCP 도구 정의
10
10
  */
11
11
  export declare const allTools: McpTool[];
12
+ /** 노출/전체 도구 수 — 헬스체크 등 표기용 파생값 (하드코딩 금지) */
13
+ export declare const TOOL_COUNTS: {
14
+ exposed: number;
15
+ total: number;
16
+ };
12
17
  export declare function registerTools(server: Server, apiClient: LawApiClient): void;
@@ -53,6 +53,11 @@ import { getLinkedOrdinances, LinkedOrdinancesSchema, getLinkedOrdinanceArticles
53
53
  import { analyzeDocument, AnalyzeDocumentSchema } from "./tools/document-analysis.js";
54
54
  import { verifyCitations, VerifyCitationsSchema } from "./tools/verify-citations.js";
55
55
  import { impactMap, ImpactMapSchema } from "./tools/impact-map.js";
56
+ import { citeCheck, CiteCheckSchema } from "./tools/cite-check.js";
57
+ import { applicableLaw, ApplicableLawSchema } from "./tools/applicable-law.js";
58
+ // 통합 진입점 (v4.4.0 — 노출 도구 수 축소용)
59
+ import { legalResearch, LegalResearchSchema } from "./tools/legal-research.js";
60
+ import { legalAnalysis, LegalAnalysisSchema } from "./tools/legal-analysis.js";
56
61
  // Chain tool imports
57
62
  import { chainLawSystem, chainLawSystemSchema, chainActionBasis, chainActionBasisSchema, chainDisputePrep, chainDisputePrepSchema, chainAmendmentTrack, chainAmendmentTrackSchema, chainOrdinanceCompare, chainOrdinanceCompareSchema, chainFullResearch, chainFullResearchSchema, chainProcedureDetail, chainProcedureDetailSchema, chainDocumentReview, chainDocumentReviewSchema, } from "./tools/chains.js";
58
63
  /**
@@ -546,6 +551,21 @@ export const allTools = [
546
551
  schema: GetArticleWithPrecedentsSchema,
547
552
  handler: getArticleWithPrecedents
548
553
  },
554
+ // === 통합 진입점 (v4.4.0) ===
555
+ // legal_research/legal_analysis가 아래 chain_*/킬러피처 12개를 대체 노출.
556
+ // 원본 도구는 allTools에 유지 — 직접 CallTool/execute_tool 하위호환.
557
+ {
558
+ name: "legal_research",
559
+ description: "[⛓리서치] 다단계 법령 리서치 통합 — 여러 API를 병렬로 엮는 복합 질문 전용. task: full_research=도메인·법령명 불명확한 자연어 질문 폴백(기본값, 예 '음주운전 처벌 기준') | law_system=법률·시행령·시행규칙 3단+위임+별표(예 '관세법 체계') | action_basis=처분·허가의 법적 근거+해석례+판례+행심(예 '영업정지 근거') | dispute_prep=불복·소송 준비, 판례+심판례+도메인 결정례(예 '과세처분 불복') | amendment_track=개정 이력+신구대조+연혁(예 '2023년 개정 뭐 바뀜') | ordinance_compare=조례 전국 비교+상위법 적합성(예 '서울시 주차 조례') | procedure_detail=절차·수수료·별표서식(예 '건축허가 절차') | document_review=계약서·약관 조항 리스크+근거법령(text 필수). 단일 조회로 답이 되면 search_law/get_law_text 쓸 것.",
560
+ schema: LegalResearchSchema,
561
+ handler: legalResearch
562
+ },
563
+ {
564
+ name: "legal_analysis",
565
+ description: "[정밀분석] 검증·분석 4종 통합. mode: verify_citations=텍스트 속 조문 인용('민법 제750조' 등)이 실존하는지 법제처 DB 교차검증, LLM 환각 방지(text 필수) | cite_check=판례 생사 확인 — 사건번호로 후속 인용 역추적+변경·폐기 감지, 한국형 Citator(caseNumber 필수) | applicable_law=사건 시점에 시행되던 법령 버전+그 시점 조문+부칙 경과조치, 행위시법 판단(lawName+date 필수, jo 선택) | impact_map=한 조문을 인용한 판례·헌재·해석례·행심·조례 역방향 그래프+mermaid(lawName+jo 필수)",
566
+ schema: LegalAnalysisSchema,
567
+ handler: legalAnalysis
568
+ },
549
569
  // === 체인 도구 (다단계 자동 실행) ===
550
570
  // 사용 원칙: 단일 조회(search_law/get_law_text)로 답이 되면 체인 쓰지 말 것.
551
571
  // 체인은 "여러 API를 병렬로 엮어야 하는" 복합 질문 전용.
@@ -618,10 +638,24 @@ export const allTools = [
618
638
  schema: ImpactMapSchema,
619
639
  handler: impactMap
620
640
  },
641
+ // === 판례 인용 추적 (v4.3 killer feature) ===
642
+ {
643
+ name: "cite_check",
644
+ description: "[판례생사] 한국형 Shepard's Citator — 사건번호(예: 2013다61381)로 ① 그 판례를 인용한 후속 판례 역추적(본문검색) ② 전원합의체 후속 판결의 변경·폐기 문구 정밀 스캔 ③ 계속인용/변경가능성 판정. '이 판례 아직 유효한가' 확인용. 변경·폐기된 판례 인용 사고 방지.",
645
+ schema: CiteCheckSchema,
646
+ handler: citeCheck
647
+ },
648
+ // === 행위시법 판단 (v4.3 killer feature) ===
649
+ {
650
+ name: "applicable_law",
651
+ description: "[행위시법] '사건 시점(예: 2023.5.10)에 적용되는 법은?' — 기준일에 시행 중이던 법령 버전(MST) 특정 + 그 시점 조문 본문 + 현행과 비교 + 이후 개정 부칙의 적용례·경과조치 자동 발췌 + 행위시법/처분시법 법리 안내. lawName + date 필수, jo 선택. LLM이 현행법으로 오답하는 것 방지.",
652
+ schema: ApplicableLawSchema,
653
+ handler: applicableLaw
654
+ },
621
655
  // === 메타 도구 (lite 프로필용) ===
622
656
  {
623
657
  name: "discover_tools",
624
- description: "[메타] 위 체인/직접 도구로 안 되는 경우. 73개 전문도구(조세심판·관세·헌재·행심·공정위·개인정보위·노동위·학칙·조약·영문법령·용어 등) 카테고리 검색",
658
+ description: "[메타] 위 도구로 안 되는 경우. 전문도구(조세심판·관세·헌재·행심·공정위·개인정보위·노동위·학칙·조약·영문법령·용어 등 80+개) 카테고리 검색",
625
659
  schema: DiscoverToolsSchema,
626
660
  handler: discoverTools
627
661
  },
@@ -652,7 +686,10 @@ function toMcpInputSchema(schema) {
652
686
  // Zod v4: z.toJSONSchema()로 직접 변환 (zod-to-json-schema는 Zod v4 미지원)
653
687
  const rawSchema = z.toJSONSchema(schema);
654
688
  if (rawSchema?.type === "object" && rawSchema?.properties) {
689
+ // apiKey는 HTTP 헤더(session-state)로 전달되는 게 정식 경로 — 광고 스키마에서 숨김.
690
+ // Zod parse는 여전히 수용하므로 인자로 넘기는 기존 클라이언트도 동작.
655
691
  const props = { ...rawSchema.properties };
692
+ delete props.apiKey;
656
693
  const required = Array.isArray(rawSchema.required)
657
694
  ? rawSchema.required.filter((k) => k !== "apiKey")
658
695
  : [];
@@ -666,39 +703,39 @@ function toMcpInputSchema(schema) {
666
703
  return rawSchema;
667
704
  }
668
705
  /**
669
- * v3 통합 프로필 — 15개 도구 노출, 나머지는 execute_tool로 접근
706
+ * v4.4.0 통합 프로필 — 9개 도구 노출, 나머지는 execute_tool로 접근
670
707
  *
671
708
  * 노출 기준:
672
709
  * 1) 체인 도구가 fallback으로 자주 호출하는 종착 도구
673
710
  * 2) discover_tools → execute_tool 왕복으로 평균 5초+ 손실 발생
674
711
  * 3) 그 외는 execute_tool 경유 유지
675
712
  *
713
+ * v4.4.0 통폐합: chain_* 8개 → legal_research(task), 킬러피처 4개
714
+ * (verify_citations/cite_check/applicable_law/impact_map) → legal_analysis(mode).
715
+ * 원본 12개는 allTools에 유지 — CallTool 직접 호출/execute_tool 하위호환.
716
+ *
676
717
  * ⚠️ get_annexes 제거 금지:
677
718
  * 헬스장 환불 케이스(trace ld-1775959823220, 79s)에서 별표 3의2를 가져오기 위해
678
719
  * discover_tools × 2 + execute_tool 헛발질로 ~15초 손실. 직노출로 해결.
679
720
  */
680
721
  const V3_EXPOSED = new Set([
681
- "chain_full_research", "chain_law_system", "chain_action_basis",
682
- "chain_dispute_prep", "chain_amendment_track", "chain_ordinance_compare",
683
- "chain_procedure_detail", "chain_document_review",
722
+ "legal_research", // v4.4.0: chain_* 8개 통합 (task 파라미터)
723
+ "legal_analysis", // v4.4.0: verify_citations/cite_check/applicable_law/impact_map 통합 (mode 파라미터)
684
724
  "search_law", "get_law_text",
685
725
  "get_annexes",
686
726
  "search_decisions", "get_decision_text",
687
727
  "discover_tools", "execute_tool",
688
- "verify_citations", // v3.5: LLM 환각 방지 인용 검증
689
- "impact_map", // v4.0: 조문 영향 그래프 (역방향 탐색 + mermaid)
690
728
  ]);
691
729
  // 이름 기반 O(1) 조회용 Map
692
- const toolMap = new Map();
730
+ // allTools는 정적 모듈 로드 시 1회만 구성 (HTTP 모드에서 요청마다 재구성 방지)
731
+ const toolMap = new Map(allTools.map(tool => [tool.name, tool]));
732
+ // 메타 도구가 전체 도구 목록 참조할 수 있도록 주입
733
+ setAllToolsRef(allTools);
734
+ // V3_EXPOSED만 노출 (나머지는 execute_tool 경유)
735
+ const exposedTools = allTools.filter(t => V3_EXPOSED.has(t.name));
736
+ /** 노출/전체 도구 수 — 헬스체크 등 표기용 파생값 (하드코딩 금지) */
737
+ export const TOOL_COUNTS = { exposed: exposedTools.length, total: allTools.length };
693
738
  export function registerTools(server, apiClient) {
694
- // Map 초기화
695
- toolMap.clear();
696
- for (const tool of allTools)
697
- toolMap.set(tool.name, tool);
698
- // 메타 도구가 전체 도구 목록 참조할 수 있도록 주입
699
- setAllToolsRef(allTools);
700
- // V3_EXPOSED 16개만 노출 (나머지는 execute_tool 경유)
701
- const exposedTools = allTools.filter(t => V3_EXPOSED.has(t.name));
702
739
  // ListTools 핸들러
703
740
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
704
741
  tools: exposedTools.map(tool => ({