law-mcp-server 0.1.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/.env.example +8 -0
- package/.eslintignore +2 -0
- package/.eslintrc.cjs +19 -0
- package/.prettierignore +2 -0
- package/.prettierrc +5 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/cache.js +18 -0
- package/dist/config.js +10 -0
- package/dist/consistency.js +88 -0
- package/dist/index.js +22 -0
- package/dist/lawApi.js +45 -0
- package/dist/mcp.js +45 -0
- package/dist/src/cache.js +18 -0
- package/dist/src/config.js +10 -0
- package/dist/src/consistency.js +94 -0
- package/dist/src/index.js +26 -0
- package/dist/src/lawApi.js +49 -0
- package/dist/src/mcp.js +60 -0
- package/dist/src/tools.js +134 -0
- package/dist/src/types.js +1 -0
- package/dist/test/integration.test.js +66 -0
- package/dist/tools.js +123 -0
- package/dist/types.js +1 -0
- package/package.json +28 -0
- package/src/cache.ts +25 -0
- package/src/config.ts +19 -0
- package/src/consistency.ts +128 -0
- package/src/index.ts +34 -0
- package/src/lawApi.ts +62 -0
- package/src/mcp.ts +87 -0
- package/src/tools.ts +162 -0
- package/src/types.ts +54 -0
- package/test/integration.test.ts +75 -0
- package/tsconfig.json +14 -0
package/.env.example
ADDED
package/.eslintignore
ADDED
package/.eslintrc.cjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
env: {
|
|
3
|
+
es2022: true,
|
|
4
|
+
node: true,
|
|
5
|
+
},
|
|
6
|
+
parser: "@typescript-eslint/parser",
|
|
7
|
+
parserOptions: {
|
|
8
|
+
sourceType: "module",
|
|
9
|
+
ecmaVersion: "latest",
|
|
10
|
+
},
|
|
11
|
+
plugins: ["@typescript-eslint"],
|
|
12
|
+
extends: [
|
|
13
|
+
"eslint:recommended",
|
|
14
|
+
"plugin:@typescript-eslint/recommended",
|
|
15
|
+
"prettier",
|
|
16
|
+
],
|
|
17
|
+
ignorePatterns: ["dist/**"],
|
|
18
|
+
rules: {},
|
|
19
|
+
};
|
package/.prettierignore
ADDED
package/.prettierrc
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yoshihiko Miyaichi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Law MCP Server Specification
|
|
2
|
+
|
|
3
|
+
This repository will host an MCP server that uses **法令API Version 2** (e-Gov) to fetch statute data and help check the consistency between internal documents and the referenced laws. The document below captures the target capabilities and operational expectations before implementation.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
- Provide MCP tools that surface law data from the official API and perform document-to-law consistency checks.
|
|
8
|
+
- Enable knowledge workers to verify whether policy drafts, contracts, or memos align with authoritative legal text.
|
|
9
|
+
- Favor transparent outputs that include sources (LawID, article numbers, URLs) and the reasoning steps used during checks.
|
|
10
|
+
|
|
11
|
+
## External Data Source
|
|
12
|
+
|
|
13
|
+
- Base: `https://laws.e-gov.go.jp/api/2/`
|
|
14
|
+
- Common endpoints (see swagger for full schema):
|
|
15
|
+
- `GET /lawdata/{LawID}` – fetch law structure and articles.
|
|
16
|
+
- `GET /lawsearch/{keyword}` – search laws by keyword.
|
|
17
|
+
- Response format: JSON (includes meta, LawName, Articles, etc.). Respect official rate limits; treat 429/503 as retryable with backoff.
|
|
18
|
+
|
|
19
|
+
## MCP Capabilities (planned tools)
|
|
20
|
+
|
|
21
|
+
- `fetch_law` – Input: `lawId` (string), optional `revisionDate`. Output: normalized law JSON plus source URL.
|
|
22
|
+
- `search_laws` – Input: `keyword` (string), optional `lawType` filter. Output: list of LawID, title, promulgation date, and API URL.
|
|
23
|
+
- `list_revisions` – Input: `lawId`. Output: known revision dates/IDs when the API supplies them.
|
|
24
|
+
- `check_consistency` – Input: `documentText`, optional `lawIds`, optional `articleHints`, `strictness` (low/medium/high). Output: matched citations, conflicting passages, and a traceable reasoning summary.
|
|
25
|
+
- `summarize_law` – Input: `lawId`, optional `articles` list. Output: concise bullet summary suitable for grounding model responses.
|
|
26
|
+
|
|
27
|
+
## Consistency Check Workflow
|
|
28
|
+
|
|
29
|
+
- Normalize the incoming document (segment by sentence/section, detect cited articles like “第○条”).
|
|
30
|
+
- Resolve target laws: use `lawIds` provided or run `search_laws` to suggest candidates.
|
|
31
|
+
- Fetch required law texts via `fetch_law`; cache responses per `LawID` to reduce API load.
|
|
32
|
+
- Align document segments to law articles using string similarity and citation hints; note exact article numbers when present.
|
|
33
|
+
- Produce findings: for each segment, mark status (`aligned`, `potential_mismatch`, `not_found`), include article references, and show snippets from both sides.
|
|
34
|
+
- Provide remediation suggestions (e.g., cite correct article, adjust wording) without altering the source document automatically.
|
|
35
|
+
|
|
36
|
+
## Server Behavior & Error Handling
|
|
37
|
+
|
|
38
|
+
- Map API errors to MCP-friendly errors with actionable messages (e.g., missing `LawID`, upstream 429, malformed parameters).
|
|
39
|
+
- Use exponential backoff on 429/503 and surface retry-after hints when present.
|
|
40
|
+
- Validate inputs early: reject empty `documentText`, overly long queries, or unsupported `lawId` formats with clear guidance.
|
|
41
|
+
- Log tool calls and upstream URLs for debugging; avoid storing document contents longer than the session.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
- Environment variables (to be wired during implementation):
|
|
46
|
+
- `LAW_API_BASE` (default `https://laws.e-gov.go.jp/api/2/`)
|
|
47
|
+
- `HTTP_TIMEOUT_MS` (default 15000)
|
|
48
|
+
- `CACHE_TTL_SECONDS` (default 900)
|
|
49
|
+
- Add `.env.example` later with the above keys; do not commit secrets.
|
|
50
|
+
|
|
51
|
+
## Implementation Notes
|
|
52
|
+
|
|
53
|
+
- Suggested stack: Node.js with a lightweight stdio JSON-RPC bridge for MCP compatibility, `undici`/`node-fetch` for HTTP, and a lightweight in-memory cache (Map/LRU). TypeScript preferred for schema safety using the Swagger spec.
|
|
54
|
+
- Define TypeScript types for API responses (LawData, Article, SearchResult) to enforce strict parsing.
|
|
55
|
+
- Keep business logic pure and testable (e.g., citation extraction, alignment scoring) independent of I/O.
|
|
56
|
+
- Expose a health endpoint or MCP tool (e.g., `ping`) for quick readiness checks.
|
|
57
|
+
|
|
58
|
+
## Getting Started
|
|
59
|
+
|
|
60
|
+
- Requirements: Node.js 18+.
|
|
61
|
+
- Install dependencies: `npm install`.
|
|
62
|
+
- Build: `npm run build`.
|
|
63
|
+
- Copy `.env.example` to `.env` and adjust if needed.
|
|
64
|
+
- Run server over stdio (JSON-RPC): `npm start` (or `npm run dev` for ts-node).
|
|
65
|
+
- Configure via environment variables in `.env` (see Configuration section). The server registers tools `fetch_law`, `search_laws`, `list_revisions`, `check_consistency`, and `summarize_law`.
|
|
66
|
+
- Quality: `npm run lint` (ESLint) / `npm run format` (Prettier).
|
|
67
|
+
|
|
68
|
+
## Usage Examples (conceptual)
|
|
69
|
+
|
|
70
|
+
- Search and fetch: “Search for 個人情報保護 and show the latest articles.” → calls `search_laws` then `fetch_law`.
|
|
71
|
+
- Consistency check: “Check this draft against 労働基準法 Articles 24 and 37; highlight mismatches.” → calls `check_consistency` with `lawIds=[...]` and article hints.
|
|
72
|
+
|
|
73
|
+
## Validation Plan (to implement)
|
|
74
|
+
|
|
75
|
+
- Unit tests for citation parsing, article alignment scoring, and API response normalization.
|
|
76
|
+
- Integration tests mocking the 法令API to cover success, 404, 429/503 retry, and malformed LawID cases.
|
|
77
|
+
- Integration test runner: `npm run test` (uses undici MockAgent; no network).
|
|
78
|
+
- Manual smoke: run MCP client (e.g., Claude Desktop) to issue `search_laws` and `check_consistency` commands.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
This specification is the starting point; refine it as implementation details solidify while keeping parity with the official 法令API Version 2 documentation.
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class MemoryCache {
|
|
2
|
+
store = new Map();
|
|
3
|
+
get(key) {
|
|
4
|
+
const entry = this.store.get(key);
|
|
5
|
+
if (!entry)
|
|
6
|
+
return undefined;
|
|
7
|
+
if (Date.now() > entry.expiresAt) {
|
|
8
|
+
this.store.delete(key);
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
return entry.value;
|
|
12
|
+
}
|
|
13
|
+
set(key, value, ttlSeconds) {
|
|
14
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
15
|
+
this.store.set(key, { value, expiresAt });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const cache = new MemoryCache();
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const toNumber = (value, fallback) => {
|
|
2
|
+
const parsed = Number(value);
|
|
3
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
4
|
+
};
|
|
5
|
+
export const config = {
|
|
6
|
+
apiBase: process.env.LAW_API_BASE?.trim() || "https://laws.e-gov.go.jp/api/2/",
|
|
7
|
+
httpTimeoutMs: toNumber(process.env.HTTP_TIMEOUT_MS, 15000),
|
|
8
|
+
cacheTtlSeconds: toNumber(process.env.CACHE_TTL_SECONDS, 900),
|
|
9
|
+
userAgent: "law-mcp-server/0.1.0"
|
|
10
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const toArray = (value) => {
|
|
2
|
+
if (!value)
|
|
3
|
+
return [];
|
|
4
|
+
return Array.isArray(value) ? value : [value];
|
|
5
|
+
};
|
|
6
|
+
const paragraphText = (paragraph) => {
|
|
7
|
+
if (!paragraph || typeof paragraph !== "object")
|
|
8
|
+
return "";
|
|
9
|
+
const p = paragraph;
|
|
10
|
+
const sentences = toArray(p.ParagraphSentence);
|
|
11
|
+
return sentences.join("\n");
|
|
12
|
+
};
|
|
13
|
+
const articleText = (article) => {
|
|
14
|
+
const paragraphs = toArray(article.Paragraph);
|
|
15
|
+
const body = paragraphs.map(paragraphText).filter(Boolean).join("\n");
|
|
16
|
+
const title = article.ArticleTitle || "";
|
|
17
|
+
return [title, body].filter(Boolean).join("\n");
|
|
18
|
+
};
|
|
19
|
+
const flattenArticles = (law) => {
|
|
20
|
+
const articles = toArray(law.LawBody?.MainProvision?.Article);
|
|
21
|
+
return articles.map((article) => ({
|
|
22
|
+
lawId: law.LawID,
|
|
23
|
+
articleNumber: article.ArticleNumber,
|
|
24
|
+
text: articleText(article)
|
|
25
|
+
}));
|
|
26
|
+
};
|
|
27
|
+
const similarityScore = (a, b) => {
|
|
28
|
+
const tokenize = (value) => value
|
|
29
|
+
.replace(/[\s、。,.\.\n\t]+/g, " ")
|
|
30
|
+
.split(" ")
|
|
31
|
+
.map((v) => v.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
const tokensA = tokenize(a.toLowerCase());
|
|
34
|
+
const tokensB = tokenize(b.toLowerCase());
|
|
35
|
+
if (!tokensA.length || !tokensB.length)
|
|
36
|
+
return 0;
|
|
37
|
+
const setB = new Set(tokensB);
|
|
38
|
+
const intersection = tokensA.filter((token) => setB.has(token)).length;
|
|
39
|
+
const union = new Set([...tokensA, ...tokensB]).size;
|
|
40
|
+
return intersection / union;
|
|
41
|
+
};
|
|
42
|
+
const extractArticleHint = (segment) => {
|
|
43
|
+
const match = segment.match(/第\s*([0-90-9一二三四五六七八九十百千]+)\s*条/);
|
|
44
|
+
return match ? match[1] : undefined;
|
|
45
|
+
};
|
|
46
|
+
const bestArticleMatch = (segment, articles) => {
|
|
47
|
+
const hint = extractArticleHint(segment);
|
|
48
|
+
if (hint) {
|
|
49
|
+
const exact = articles.find((a) => a.articleNumber && a.articleNumber.includes(hint));
|
|
50
|
+
if (exact)
|
|
51
|
+
return exact;
|
|
52
|
+
}
|
|
53
|
+
let best;
|
|
54
|
+
let bestScore = 0;
|
|
55
|
+
for (const article of articles) {
|
|
56
|
+
const score = similarityScore(segment, article.text);
|
|
57
|
+
if (score > bestScore) {
|
|
58
|
+
best = article;
|
|
59
|
+
bestScore = score;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return best;
|
|
63
|
+
};
|
|
64
|
+
export const checkConsistency = (documentText, laws) => {
|
|
65
|
+
const segments = documentText
|
|
66
|
+
.split(/\n+/)
|
|
67
|
+
.map((s) => s.trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
const flattened = laws.flatMap(flattenArticles).filter((a) => a.text.trim().length > 0);
|
|
70
|
+
const findings = segments.map((segment) => {
|
|
71
|
+
const match = bestArticleMatch(segment, flattened);
|
|
72
|
+
if (!match) {
|
|
73
|
+
return { segment, status: "not_found" };
|
|
74
|
+
}
|
|
75
|
+
const score = similarityScore(segment, match.text);
|
|
76
|
+
const status = score >= 0.6 ? "aligned" : score >= 0.25 ? "potential_mismatch" : "not_found";
|
|
77
|
+
return {
|
|
78
|
+
segment,
|
|
79
|
+
status,
|
|
80
|
+
lawId: match.lawId,
|
|
81
|
+
articleNumber: match.articleNumber,
|
|
82
|
+
lawSnippet: match.text.slice(0, 400),
|
|
83
|
+
score: Number(score.toFixed(3))
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
const matchedLawIds = Array.from(new Set(findings.map((f) => f.lawId).filter(Boolean)));
|
|
87
|
+
return { findings, matchedLawIds };
|
|
88
|
+
};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { StdioJsonRpcServer } from "./mcp.js";
|
|
2
|
+
import { tools, resolveTool } from "./tools.js";
|
|
3
|
+
const server = new StdioJsonRpcServer();
|
|
4
|
+
server.register("ping", async () => ({ ok: true }));
|
|
5
|
+
server.register("tools/list", async () => ({
|
|
6
|
+
tools: tools.map((tool) => ({
|
|
7
|
+
name: tool.name,
|
|
8
|
+
description: tool.description,
|
|
9
|
+
inputSchema: tool.inputSchema,
|
|
10
|
+
outputSchema: tool.outputSchema
|
|
11
|
+
}))
|
|
12
|
+
}));
|
|
13
|
+
server.register("tools/call", async (params) => {
|
|
14
|
+
const { name, arguments: args } = params;
|
|
15
|
+
const tool = resolveTool(name);
|
|
16
|
+
if (!tool) {
|
|
17
|
+
throw new Error(`Tool ${name} is not available`);
|
|
18
|
+
}
|
|
19
|
+
const result = await tool.handler(args || {});
|
|
20
|
+
return { content: result };
|
|
21
|
+
});
|
|
22
|
+
server.start();
|
package/dist/lawApi.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { fetch } from "undici";
|
|
2
|
+
import { cache } from "./cache.js";
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
const withTimeout = async (promise, ms) => {
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
7
|
+
try {
|
|
8
|
+
return await promise;
|
|
9
|
+
}
|
|
10
|
+
finally {
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const request = async (url) => {
|
|
15
|
+
const res = await withTimeout(fetch(url, {
|
|
16
|
+
headers: {
|
|
17
|
+
"User-Agent": config.userAgent,
|
|
18
|
+
Accept: "application/json"
|
|
19
|
+
}
|
|
20
|
+
}), config.httpTimeoutMs);
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const body = await res.text();
|
|
23
|
+
throw new Error(`Request failed ${res.status}: ${body}`);
|
|
24
|
+
}
|
|
25
|
+
const data = (await res.json());
|
|
26
|
+
return data;
|
|
27
|
+
};
|
|
28
|
+
export const fetchLawData = async (lawId, revisionDate) => {
|
|
29
|
+
const cacheKey = `law:${lawId}:${revisionDate || "latest"}`;
|
|
30
|
+
const cached = cache.get(cacheKey);
|
|
31
|
+
if (cached)
|
|
32
|
+
return cached;
|
|
33
|
+
const base = config.apiBase.endsWith("/") ? config.apiBase : `${config.apiBase}/`;
|
|
34
|
+
const url = new URL(`lawdata/${encodeURIComponent(lawId)}`, base);
|
|
35
|
+
if (revisionDate)
|
|
36
|
+
url.searchParams.set("revision", revisionDate);
|
|
37
|
+
const data = await request(url.toString());
|
|
38
|
+
cache.set(cacheKey, data, config.cacheTtlSeconds);
|
|
39
|
+
return data;
|
|
40
|
+
};
|
|
41
|
+
export const searchLaws = async (keyword) => {
|
|
42
|
+
const base = config.apiBase.endsWith("/") ? config.apiBase : `${config.apiBase}/`;
|
|
43
|
+
const url = new URL(`lawsearch/${encodeURIComponent(keyword)}`, base);
|
|
44
|
+
return request(url.toString());
|
|
45
|
+
};
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
export class StdioJsonRpcServer {
|
|
3
|
+
handlers = new Map();
|
|
4
|
+
register(method, handler) {
|
|
5
|
+
this.handlers.set(method, handler);
|
|
6
|
+
}
|
|
7
|
+
start() {
|
|
8
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
9
|
+
rl.on("line", async (line) => {
|
|
10
|
+
let message;
|
|
11
|
+
try {
|
|
12
|
+
message = JSON.parse(line);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (!message || message.jsonrpc !== "2.0" || message.method === undefined) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const { id, method, params } = message;
|
|
21
|
+
const handler = this.handlers.get(method);
|
|
22
|
+
if (!handler) {
|
|
23
|
+
if (id !== undefined) {
|
|
24
|
+
this.send({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } });
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const result = await handler(params ?? {});
|
|
30
|
+
if (id !== undefined) {
|
|
31
|
+
this.send({ jsonrpc: "2.0", id, result });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (id !== undefined) {
|
|
36
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
37
|
+
this.send({ jsonrpc: "2.0", id, error: { code: -32000, message } });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
send(payload) {
|
|
43
|
+
process.stdout.write(JSON.stringify(payload) + "\n");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class MemoryCache {
|
|
2
|
+
store = new Map();
|
|
3
|
+
get(key) {
|
|
4
|
+
const entry = this.store.get(key);
|
|
5
|
+
if (!entry)
|
|
6
|
+
return undefined;
|
|
7
|
+
if (Date.now() > entry.expiresAt) {
|
|
8
|
+
this.store.delete(key);
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
return entry.value;
|
|
12
|
+
}
|
|
13
|
+
set(key, value, ttlSeconds) {
|
|
14
|
+
const expiresAt = Date.now() + ttlSeconds * 1000;
|
|
15
|
+
this.store.set(key, { value, expiresAt });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export const cache = new MemoryCache();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const toNumber = (value, fallback) => {
|
|
2
|
+
const parsed = Number(value);
|
|
3
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
4
|
+
};
|
|
5
|
+
export const config = {
|
|
6
|
+
apiBase: process.env.LAW_API_BASE?.trim() || "https://laws.e-gov.go.jp/api/2/",
|
|
7
|
+
httpTimeoutMs: toNumber(process.env.HTTP_TIMEOUT_MS, 15000),
|
|
8
|
+
cacheTtlSeconds: toNumber(process.env.CACHE_TTL_SECONDS, 900),
|
|
9
|
+
userAgent: "law-mcp-server/0.1.0",
|
|
10
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const toArray = (value) => {
|
|
2
|
+
if (!value)
|
|
3
|
+
return [];
|
|
4
|
+
return Array.isArray(value) ? value : [value];
|
|
5
|
+
};
|
|
6
|
+
const paragraphText = (paragraph) => {
|
|
7
|
+
if (!paragraph || typeof paragraph !== "object")
|
|
8
|
+
return "";
|
|
9
|
+
const p = paragraph;
|
|
10
|
+
const sentences = toArray(p.ParagraphSentence);
|
|
11
|
+
return sentences.join("\n");
|
|
12
|
+
};
|
|
13
|
+
const articleText = (article) => {
|
|
14
|
+
const paragraphs = toArray(article.Paragraph);
|
|
15
|
+
const body = paragraphs.map(paragraphText).filter(Boolean).join("\n");
|
|
16
|
+
const title = article.ArticleTitle || "";
|
|
17
|
+
return [title, body].filter(Boolean).join("\n");
|
|
18
|
+
};
|
|
19
|
+
const flattenArticles = (law) => {
|
|
20
|
+
const articles = toArray(law.LawBody?.MainProvision?.Article);
|
|
21
|
+
return articles.map((article) => ({
|
|
22
|
+
lawId: law.LawID,
|
|
23
|
+
articleNumber: article.ArticleNumber,
|
|
24
|
+
text: articleText(article),
|
|
25
|
+
}));
|
|
26
|
+
};
|
|
27
|
+
const similarityScore = (a, b) => {
|
|
28
|
+
const tokenize = (value) => value
|
|
29
|
+
.replace(/[\s、。,..\n\t]+/g, " ")
|
|
30
|
+
.split(" ")
|
|
31
|
+
.map((v) => v.trim())
|
|
32
|
+
.filter(Boolean);
|
|
33
|
+
const tokensA = tokenize(a.toLowerCase());
|
|
34
|
+
const tokensB = tokenize(b.toLowerCase());
|
|
35
|
+
if (!tokensA.length || !tokensB.length)
|
|
36
|
+
return 0;
|
|
37
|
+
const setB = new Set(tokensB);
|
|
38
|
+
const intersection = tokensA.filter((token) => setB.has(token)).length;
|
|
39
|
+
const union = new Set([...tokensA, ...tokensB]).size;
|
|
40
|
+
return intersection / union;
|
|
41
|
+
};
|
|
42
|
+
const extractArticleHint = (segment) => {
|
|
43
|
+
const match = segment.match(/第\s*([0-90-9一二三四五六七八九十百千]+)\s*条/);
|
|
44
|
+
return match ? match[1] : undefined;
|
|
45
|
+
};
|
|
46
|
+
const bestArticleMatch = (segment, articles) => {
|
|
47
|
+
const hint = extractArticleHint(segment);
|
|
48
|
+
if (hint) {
|
|
49
|
+
const exact = articles.find((a) => a.articleNumber && a.articleNumber.includes(hint));
|
|
50
|
+
if (exact)
|
|
51
|
+
return exact;
|
|
52
|
+
}
|
|
53
|
+
let best;
|
|
54
|
+
let bestScore = 0;
|
|
55
|
+
for (const article of articles) {
|
|
56
|
+
const score = similarityScore(segment, article.text);
|
|
57
|
+
if (score > bestScore) {
|
|
58
|
+
best = article;
|
|
59
|
+
bestScore = score;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return best;
|
|
63
|
+
};
|
|
64
|
+
export const checkConsistency = (documentText, laws) => {
|
|
65
|
+
const segments = documentText
|
|
66
|
+
.split(/\n+/)
|
|
67
|
+
.map((s) => s.trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
const flattened = laws
|
|
70
|
+
.flatMap(flattenArticles)
|
|
71
|
+
.filter((a) => a.text.trim().length > 0);
|
|
72
|
+
const findings = segments.map((segment) => {
|
|
73
|
+
const match = bestArticleMatch(segment, flattened);
|
|
74
|
+
if (!match) {
|
|
75
|
+
return { segment, status: "not_found" };
|
|
76
|
+
}
|
|
77
|
+
const score = similarityScore(segment, match.text);
|
|
78
|
+
const status = score >= 0.6
|
|
79
|
+
? "aligned"
|
|
80
|
+
: score >= 0.25
|
|
81
|
+
? "potential_mismatch"
|
|
82
|
+
: "not_found";
|
|
83
|
+
return {
|
|
84
|
+
segment,
|
|
85
|
+
status,
|
|
86
|
+
lawId: match.lawId,
|
|
87
|
+
articleNumber: match.articleNumber,
|
|
88
|
+
lawSnippet: match.text.slice(0, 400),
|
|
89
|
+
score: Number(score.toFixed(3)),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
const matchedLawIds = Array.from(new Set(findings.map((f) => f.lawId).filter(Boolean)));
|
|
93
|
+
return { findings, matchedLawIds };
|
|
94
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { StdioJsonRpcServer } from "./mcp.js";
|
|
2
|
+
import { tools, resolveTool } from "./tools.js";
|
|
3
|
+
const server = new StdioJsonRpcServer();
|
|
4
|
+
server.register("ping", async () => ({ ok: true }));
|
|
5
|
+
server.register("tools/list", async () => ({
|
|
6
|
+
tools: tools.map((tool) => ({
|
|
7
|
+
name: tool.name,
|
|
8
|
+
description: tool.description,
|
|
9
|
+
inputSchema: tool.inputSchema,
|
|
10
|
+
outputSchema: tool.outputSchema,
|
|
11
|
+
})),
|
|
12
|
+
}));
|
|
13
|
+
server.register("tools/call", async (params) => {
|
|
14
|
+
const payload = (params ?? {});
|
|
15
|
+
if (typeof payload.name !== "string") {
|
|
16
|
+
throw new Error("Tool name is required and must be a string");
|
|
17
|
+
}
|
|
18
|
+
const tool = resolveTool(payload.name);
|
|
19
|
+
if (!tool) {
|
|
20
|
+
throw new Error(`Tool ${payload.name} is not available`);
|
|
21
|
+
}
|
|
22
|
+
const args = payload.arguments && typeof payload.arguments === "object" ? payload.arguments : {};
|
|
23
|
+
const result = await tool.handler(args);
|
|
24
|
+
return { content: result };
|
|
25
|
+
});
|
|
26
|
+
server.start();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { fetch } from "undici";
|
|
2
|
+
import { cache } from "./cache.js";
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
const withTimeout = async (promise, ms) => {
|
|
5
|
+
const controller = new AbortController();
|
|
6
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
7
|
+
try {
|
|
8
|
+
return await promise;
|
|
9
|
+
}
|
|
10
|
+
finally {
|
|
11
|
+
clearTimeout(timer);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const request = async (url) => {
|
|
15
|
+
const res = await withTimeout(fetch(url, {
|
|
16
|
+
headers: {
|
|
17
|
+
"User-Agent": config.userAgent,
|
|
18
|
+
Accept: "application/json",
|
|
19
|
+
},
|
|
20
|
+
}), config.httpTimeoutMs);
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
const body = await res.text();
|
|
23
|
+
throw new Error(`Request failed ${res.status}: ${body}`);
|
|
24
|
+
}
|
|
25
|
+
const data = (await res.json());
|
|
26
|
+
return data;
|
|
27
|
+
};
|
|
28
|
+
export const fetchLawData = async (lawId, revisionDate) => {
|
|
29
|
+
const cacheKey = `law:${lawId}:${revisionDate || "latest"}`;
|
|
30
|
+
const cached = cache.get(cacheKey);
|
|
31
|
+
if (cached)
|
|
32
|
+
return cached;
|
|
33
|
+
const base = config.apiBase.endsWith("/")
|
|
34
|
+
? config.apiBase
|
|
35
|
+
: `${config.apiBase}/`;
|
|
36
|
+
const url = new URL(`lawdata/${encodeURIComponent(lawId)}`, base);
|
|
37
|
+
if (revisionDate)
|
|
38
|
+
url.searchParams.set("revision", revisionDate);
|
|
39
|
+
const data = await request(url.toString());
|
|
40
|
+
cache.set(cacheKey, data, config.cacheTtlSeconds);
|
|
41
|
+
return data;
|
|
42
|
+
};
|
|
43
|
+
export const searchLaws = async (keyword) => {
|
|
44
|
+
const base = config.apiBase.endsWith("/")
|
|
45
|
+
? config.apiBase
|
|
46
|
+
: `${config.apiBase}/`;
|
|
47
|
+
const url = new URL(`lawsearch/${encodeURIComponent(keyword)}`, base);
|
|
48
|
+
return request(url.toString());
|
|
49
|
+
};
|
package/dist/src/mcp.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
export class StdioJsonRpcServer {
|
|
3
|
+
handlers = new Map();
|
|
4
|
+
register(method, handler) {
|
|
5
|
+
this.handlers.set(method, handler);
|
|
6
|
+
}
|
|
7
|
+
start() {
|
|
8
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
9
|
+
rl.on("line", async (line) => {
|
|
10
|
+
const message = this.parseMessage(line);
|
|
11
|
+
if (!message)
|
|
12
|
+
return;
|
|
13
|
+
const { id, method, params } = message;
|
|
14
|
+
const handler = this.handlers.get(method);
|
|
15
|
+
if (!handler) {
|
|
16
|
+
if (id !== undefined) {
|
|
17
|
+
this.send({
|
|
18
|
+
jsonrpc: "2.0",
|
|
19
|
+
id,
|
|
20
|
+
error: { code: -32601, message: "Method not found" },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
const result = await handler(params ?? {});
|
|
27
|
+
if (id !== undefined) {
|
|
28
|
+
this.send({ jsonrpc: "2.0", id, result });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (id !== undefined) {
|
|
33
|
+
const messageText = error instanceof Error ? error.message : String(error);
|
|
34
|
+
this.send({
|
|
35
|
+
jsonrpc: "2.0",
|
|
36
|
+
id,
|
|
37
|
+
error: { code: -32000, message: messageText },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
parseMessage(line) {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(line);
|
|
46
|
+
if (!parsed ||
|
|
47
|
+
parsed.jsonrpc !== "2.0" ||
|
|
48
|
+
typeof parsed.method !== "string") {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
return parsed;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
send(payload) {
|
|
58
|
+
process.stdout.write(JSON.stringify(payload) + "\n");
|
|
59
|
+
}
|
|
60
|
+
}
|