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.
@@ -0,0 +1,134 @@
1
+ import { fetchLawData, searchLaws } from "./lawApi.js";
2
+ import { checkConsistency } from "./consistency.js";
3
+ const requireString = (value, field) => {
4
+ if (typeof value !== "string" || value.trim().length === 0) {
5
+ throw new Error(`Field ${field} is required`);
6
+ }
7
+ return value.trim();
8
+ };
9
+ const requireArrayOfStrings = (value, field) => {
10
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
11
+ throw new Error(`Field ${field} must be an array of strings`);
12
+ }
13
+ return value;
14
+ };
15
+ const fetchLaw = {
16
+ name: "fetch_law",
17
+ description: "Fetch a law by LawID and optional revision date",
18
+ inputSchema: {
19
+ type: "object",
20
+ properties: {
21
+ lawId: { type: "string" },
22
+ revisionDate: { type: "string" },
23
+ },
24
+ required: ["lawId"],
25
+ },
26
+ handler: async (input) => {
27
+ const lawId = requireString(input.lawId, "lawId");
28
+ const revisionDate = typeof input.revisionDate === "string" ? input.revisionDate : undefined;
29
+ const data = await fetchLawData(lawId, revisionDate);
30
+ return [{ type: "json", data }];
31
+ },
32
+ };
33
+ const search = {
34
+ name: "search_laws",
35
+ description: "Search laws by keyword",
36
+ inputSchema: {
37
+ type: "object",
38
+ properties: {
39
+ keyword: { type: "string" },
40
+ },
41
+ required: ["keyword"],
42
+ },
43
+ handler: async (input) => {
44
+ const keyword = requireString(input.keyword, "keyword");
45
+ const results = await searchLaws(keyword);
46
+ return [{ type: "json", data: results }];
47
+ },
48
+ };
49
+ const summarize = {
50
+ name: "summarize_law",
51
+ description: "Summarize a law or selected articles",
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: {
55
+ lawId: { type: "string" },
56
+ articles: { type: "array", items: { type: "string" } },
57
+ },
58
+ required: ["lawId"],
59
+ },
60
+ handler: async (input) => {
61
+ const lawId = requireString(input.lawId, "lawId");
62
+ const articlesFilter = Array.isArray(input.articles)
63
+ ? requireArrayOfStrings(input.articles, "articles")
64
+ : undefined;
65
+ const data = await fetchLawData(lawId);
66
+ const articles = Array.isArray(data.LawBody?.MainProvision?.Article)
67
+ ? data.LawBody?.MainProvision?.Article
68
+ : data.LawBody?.MainProvision?.Article
69
+ ? [data.LawBody.MainProvision.Article]
70
+ : [];
71
+ const filtered = articlesFilter
72
+ ? articles.filter((article) => article.ArticleNumber &&
73
+ articlesFilter.includes(article.ArticleNumber))
74
+ : articles;
75
+ const body = filtered
76
+ .map((article) => `${article.ArticleNumber || ""} ${article.ArticleTitle || ""}`.trim())
77
+ .slice(0, 10)
78
+ .join("\n");
79
+ return [{ type: "text", text: body || "No articles available" }];
80
+ },
81
+ };
82
+ const listRevisions = {
83
+ name: "list_revisions",
84
+ description: "List known revisions for a law if provided by the API",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ lawId: { type: "string" },
89
+ },
90
+ required: ["lawId"],
91
+ },
92
+ handler: async (input) => {
93
+ const lawId = requireString(input.lawId, "lawId");
94
+ const data = await fetchLawData(lawId);
95
+ const revisions = Array.isArray(data.revisions)
96
+ ? data.revisions
97
+ : [];
98
+ return [{ type: "json", data: { lawId, revisions } }];
99
+ },
100
+ };
101
+ const check = {
102
+ name: "check_consistency",
103
+ description: "Check a document against one or more laws",
104
+ inputSchema: {
105
+ type: "object",
106
+ properties: {
107
+ documentText: { type: "string" },
108
+ lawIds: { type: "array", items: { type: "string" } },
109
+ articleHints: { type: "array", items: { type: "string" } },
110
+ strictness: { type: "string", enum: ["low", "medium", "high"] },
111
+ },
112
+ required: ["documentText"],
113
+ },
114
+ handler: async (input) => {
115
+ const documentText = requireString(input.documentText, "documentText");
116
+ const lawIds = Array.isArray(input.lawIds)
117
+ ? requireArrayOfStrings(input.lawIds, "lawIds")
118
+ : [];
119
+ if (!lawIds.length) {
120
+ throw new Error("At least one lawId is required for consistency checks");
121
+ }
122
+ const laws = await Promise.all(lawIds.map((lawId) => fetchLawData(lawId)));
123
+ const output = checkConsistency(documentText, laws);
124
+ return [{ type: "json", data: output }];
125
+ },
126
+ };
127
+ export const tools = [
128
+ fetchLaw,
129
+ search,
130
+ listRevisions,
131
+ check,
132
+ summarize,
133
+ ];
134
+ export const resolveTool = (name) => tools.find((tool) => tool.name === name);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,66 @@
1
+ import assert from "node:assert";
2
+ import { MockAgent, setGlobalDispatcher } from "undici";
3
+ import { fetchLawData, searchLaws } from "../src/lawApi.js";
4
+ import { checkConsistency } from "../src/consistency.js";
5
+ const mockAgent = new MockAgent();
6
+ mockAgent.disableNetConnect();
7
+ const mockPool = mockAgent.get("https://laws.e-gov.go.jp");
8
+ mockPool
9
+ .intercept({ path: "/api/2/lawdata/TEST-LAW", method: "GET" })
10
+ .reply(200, {
11
+ LawID: "TEST-LAW",
12
+ LawName: "テスト法",
13
+ LawBody: {
14
+ MainProvision: {
15
+ Article: [
16
+ {
17
+ ArticleNumber: "第1条",
18
+ ArticleTitle: "(目的)",
19
+ Paragraph: {
20
+ ParagraphSentence: "この法律はテスト目的のために存在する。",
21
+ },
22
+ },
23
+ {
24
+ ArticleNumber: "第2条",
25
+ ArticleTitle: "(定義)",
26
+ Paragraph: { ParagraphSentence: "用語の定義を定める。" },
27
+ },
28
+ ],
29
+ },
30
+ },
31
+ });
32
+ mockPool
33
+ .intercept({
34
+ path: "/api/2/lawsearch/%E3%83%86%E3%82%B9%E3%83%88",
35
+ method: "GET",
36
+ })
37
+ .reply(200, {
38
+ numberOfHits: 1,
39
+ referencelaw: {
40
+ LawID: "TEST-LAW",
41
+ LawName: "テスト法",
42
+ PromulgationDate: "2024-01-01",
43
+ },
44
+ });
45
+ setGlobalDispatcher(mockAgent);
46
+ const run = async () => {
47
+ // fetchLawData returns structured law
48
+ const law = await fetchLawData("TEST-LAW");
49
+ assert.strictEqual(law.LawID, "TEST-LAW");
50
+ assert.ok(law.LawBody?.MainProvision?.Article);
51
+ // searchLaws returns the mocked hit
52
+ const search = await searchLaws("テスト");
53
+ assert.strictEqual(search.numberOfHits, 1);
54
+ // consistency check aligns a segment with 第1条
55
+ const doc = "この法律はテスト目的のために存在する。";
56
+ const result = checkConsistency(doc, [law]);
57
+ assert.strictEqual(result.findings.length, 1);
58
+ const finding = result.findings[0];
59
+ assert.notStrictEqual(finding.status, "not_found");
60
+ assert.strictEqual(finding.articleNumber, "第1条");
61
+ console.log("integration tests passed");
62
+ };
63
+ run().catch((error) => {
64
+ console.error(error);
65
+ process.exitCode = 1;
66
+ });
package/dist/tools.js ADDED
@@ -0,0 +1,123 @@
1
+ import { fetchLawData, searchLaws } from "./lawApi.js";
2
+ import { checkConsistency } from "./consistency.js";
3
+ const requireString = (value, field) => {
4
+ if (typeof value !== "string" || value.trim().length === 0) {
5
+ throw new Error(`Field ${field} is required`);
6
+ }
7
+ return value.trim();
8
+ };
9
+ const requireArrayOfStrings = (value, field) => {
10
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
11
+ throw new Error(`Field ${field} must be an array of strings`);
12
+ }
13
+ return value;
14
+ };
15
+ const fetchLaw = {
16
+ name: "fetch_law",
17
+ description: "Fetch a law by LawID and optional revision date",
18
+ inputSchema: {
19
+ type: "object",
20
+ properties: {
21
+ lawId: { type: "string" },
22
+ revisionDate: { type: "string" }
23
+ },
24
+ required: ["lawId"]
25
+ },
26
+ handler: async (input) => {
27
+ const lawId = requireString(input.lawId, "lawId");
28
+ const revisionDate = typeof input.revisionDate === "string" ? input.revisionDate : undefined;
29
+ const data = await fetchLawData(lawId, revisionDate);
30
+ return [{ type: "json", data }];
31
+ }
32
+ };
33
+ const search = {
34
+ name: "search_laws",
35
+ description: "Search laws by keyword",
36
+ inputSchema: {
37
+ type: "object",
38
+ properties: {
39
+ keyword: { type: "string" }
40
+ },
41
+ required: ["keyword"]
42
+ },
43
+ handler: async (input) => {
44
+ const keyword = requireString(input.keyword, "keyword");
45
+ const results = await searchLaws(keyword);
46
+ return [{ type: "json", data: results }];
47
+ }
48
+ };
49
+ const summarize = {
50
+ name: "summarize_law",
51
+ description: "Summarize a law or selected articles",
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: {
55
+ lawId: { type: "string" },
56
+ articles: { type: "array", items: { type: "string" } }
57
+ },
58
+ required: ["lawId"]
59
+ },
60
+ handler: async (input) => {
61
+ const lawId = requireString(input.lawId, "lawId");
62
+ const articlesFilter = Array.isArray(input.articles) ? requireArrayOfStrings(input.articles, "articles") : undefined;
63
+ const data = await fetchLawData(lawId);
64
+ const articles = Array.isArray(data.LawBody?.MainProvision?.Article)
65
+ ? data.LawBody?.MainProvision?.Article
66
+ : data.LawBody?.MainProvision?.Article
67
+ ? [data.LawBody.MainProvision.Article]
68
+ : [];
69
+ const filtered = articlesFilter
70
+ ? articles.filter((article) => article.ArticleNumber && articlesFilter.includes(article.ArticleNumber))
71
+ : articles;
72
+ const body = filtered
73
+ .map((article) => `${article.ArticleNumber || ""} ${article.ArticleTitle || ""}`.trim())
74
+ .slice(0, 10)
75
+ .join("\n");
76
+ return [{ type: "text", text: body || "No articles available" }];
77
+ }
78
+ };
79
+ const listRevisions = {
80
+ name: "list_revisions",
81
+ description: "List known revisions for a law if provided by the API",
82
+ inputSchema: {
83
+ type: "object",
84
+ properties: {
85
+ lawId: { type: "string" }
86
+ },
87
+ required: ["lawId"]
88
+ },
89
+ handler: async (input) => {
90
+ const lawId = requireString(input.lawId, "lawId");
91
+ const data = await fetchLawData(lawId);
92
+ const revisions = Array.isArray(data.revisions)
93
+ ? data.revisions
94
+ : [];
95
+ return [{ type: "json", data: { lawId, revisions } }];
96
+ }
97
+ };
98
+ const check = {
99
+ name: "check_consistency",
100
+ description: "Check a document against one or more laws",
101
+ inputSchema: {
102
+ type: "object",
103
+ properties: {
104
+ documentText: { type: "string" },
105
+ lawIds: { type: "array", items: { type: "string" } },
106
+ articleHints: { type: "array", items: { type: "string" } },
107
+ strictness: { type: "string", enum: ["low", "medium", "high"] }
108
+ },
109
+ required: ["documentText"]
110
+ },
111
+ handler: async (input) => {
112
+ const documentText = requireString(input.documentText, "documentText");
113
+ const lawIds = Array.isArray(input.lawIds) ? requireArrayOfStrings(input.lawIds, "lawIds") : [];
114
+ if (!lawIds.length) {
115
+ throw new Error("At least one lawId is required for consistency checks");
116
+ }
117
+ const laws = await Promise.all(lawIds.map((lawId) => fetchLawData(lawId)));
118
+ const output = checkConsistency(documentText, laws);
119
+ return [{ type: "json", data: output }];
120
+ }
121
+ };
122
+ export const tools = [fetchLaw, search, listRevisions, check, summarize];
123
+ export const resolveTool = (name) => tools.find((tool) => tool.name === name);
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "law-mcp-server",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "MCP server for e-Gov law API consistency checks",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "start": "node dist/index.js",
10
+ "dev": "ts-node src/index.ts",
11
+ "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"",
12
+ "format": "prettier --write .",
13
+ "test": "npm run build && node dist/test/integration.test.js"
14
+ },
15
+ "dependencies": {
16
+ "undici": "^6.13.0"
17
+ },
18
+ "devDependencies": {
19
+ "ts-node": "^10.9.2",
20
+ "typescript": "5.3.3",
21
+ "@types/node": "^20.11.0",
22
+ "eslint": "^8.57.0",
23
+ "@typescript-eslint/eslint-plugin": "^6.21.0",
24
+ "@typescript-eslint/parser": "^6.21.0",
25
+ "eslint-config-prettier": "^9.1.0",
26
+ "prettier": "^3.2.5"
27
+ }
28
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,25 @@
1
+ type Entry<T> = {
2
+ value: T;
3
+ expiresAt: number;
4
+ };
5
+
6
+ export class MemoryCache<T> {
7
+ private store = new Map<string, Entry<T>>();
8
+
9
+ get(key: string): T | undefined {
10
+ const entry = this.store.get(key);
11
+ if (!entry) return undefined;
12
+ if (Date.now() > entry.expiresAt) {
13
+ this.store.delete(key);
14
+ return undefined;
15
+ }
16
+ return entry.value;
17
+ }
18
+
19
+ set(key: string, value: T, ttlSeconds: number) {
20
+ const expiresAt = Date.now() + ttlSeconds * 1000;
21
+ this.store.set(key, { value, expiresAt });
22
+ }
23
+ }
24
+
25
+ export const cache = new MemoryCache<unknown>();
package/src/config.ts ADDED
@@ -0,0 +1,19 @@
1
+ export type AppConfig = {
2
+ apiBase: string;
3
+ httpTimeoutMs: number;
4
+ cacheTtlSeconds: number;
5
+ userAgent: string;
6
+ };
7
+
8
+ const toNumber = (value: string | undefined, fallback: number) => {
9
+ const parsed = Number(value);
10
+ return Number.isFinite(parsed) ? parsed : fallback;
11
+ };
12
+
13
+ export const config: AppConfig = {
14
+ apiBase:
15
+ process.env.LAW_API_BASE?.trim() || "https://laws.e-gov.go.jp/api/2/",
16
+ httpTimeoutMs: toNumber(process.env.HTTP_TIMEOUT_MS, 15000),
17
+ cacheTtlSeconds: toNumber(process.env.CACHE_TTL_SECONDS, 900),
18
+ userAgent: "law-mcp-server/0.1.0",
19
+ };
@@ -0,0 +1,128 @@
1
+ import {
2
+ LawArticle,
3
+ LawData,
4
+ ConsistencyFinding,
5
+ ConsistencyOutput,
6
+ } from "./types.js";
7
+
8
+ type FlattenedArticle = {
9
+ lawId: string;
10
+ articleNumber?: string;
11
+ text: string;
12
+ };
13
+
14
+ const toArray = <T>(value: T | T[] | undefined): T[] => {
15
+ if (!value) return [];
16
+ return Array.isArray(value) ? value : [value];
17
+ };
18
+
19
+ const paragraphText = (paragraph: unknown): string => {
20
+ if (!paragraph || typeof paragraph !== "object") return "";
21
+ const p = paragraph as { ParagraphSentence?: string | string[] };
22
+ const sentences = toArray(p.ParagraphSentence);
23
+ return sentences.join("\n");
24
+ };
25
+
26
+ const articleText = (article: LawArticle): string => {
27
+ const paragraphs = toArray(article.Paragraph);
28
+ const body = paragraphs.map(paragraphText).filter(Boolean).join("\n");
29
+ const title = article.ArticleTitle || "";
30
+ return [title, body].filter(Boolean).join("\n");
31
+ };
32
+
33
+ const flattenArticles = (law: LawData): FlattenedArticle[] => {
34
+ const articles = toArray(law.LawBody?.MainProvision?.Article);
35
+ return articles.map((article) => ({
36
+ lawId: law.LawID,
37
+ articleNumber: article.ArticleNumber,
38
+ text: articleText(article),
39
+ }));
40
+ };
41
+
42
+ const similarityScore = (a: string, b: string): number => {
43
+ const tokenize = (value: string) =>
44
+ value
45
+ .replace(/[\s、。,..\n\t]+/g, " ")
46
+ .split(" ")
47
+ .map((v) => v.trim())
48
+ .filter(Boolean);
49
+ const tokensA = tokenize(a.toLowerCase());
50
+ const tokensB = tokenize(b.toLowerCase());
51
+ if (!tokensA.length || !tokensB.length) return 0;
52
+ const setB = new Set(tokensB);
53
+ const intersection = tokensA.filter((token) => setB.has(token)).length;
54
+ const union = new Set([...tokensA, ...tokensB]).size;
55
+ return intersection / union;
56
+ };
57
+
58
+ const extractArticleHint = (segment: string): string | undefined => {
59
+ const match = segment.match(
60
+ /第\s*([0-90-9一二三四五六七八九十百千]+)\s*条/
61
+ );
62
+ return match ? match[1] : undefined;
63
+ };
64
+
65
+ const bestArticleMatch = (
66
+ segment: string,
67
+ articles: FlattenedArticle[]
68
+ ): FlattenedArticle | undefined => {
69
+ const hint = extractArticleHint(segment);
70
+ if (hint) {
71
+ const exact = articles.find(
72
+ (a) => a.articleNumber && a.articleNumber.includes(hint)
73
+ );
74
+ if (exact) return exact;
75
+ }
76
+ let best: FlattenedArticle | undefined;
77
+ let bestScore = 0;
78
+ for (const article of articles) {
79
+ const score = similarityScore(segment, article.text);
80
+ if (score > bestScore) {
81
+ best = article;
82
+ bestScore = score;
83
+ }
84
+ }
85
+ return best;
86
+ };
87
+
88
+ export const checkConsistency = (
89
+ documentText: string,
90
+ laws: LawData[]
91
+ ): ConsistencyOutput => {
92
+ const segments = documentText
93
+ .split(/\n+/)
94
+ .map((s) => s.trim())
95
+ .filter(Boolean);
96
+
97
+ const flattened = laws
98
+ .flatMap(flattenArticles)
99
+ .filter((a) => a.text.trim().length > 0);
100
+
101
+ const findings: ConsistencyFinding[] = segments.map((segment) => {
102
+ const match = bestArticleMatch(segment, flattened);
103
+ if (!match) {
104
+ return { segment, status: "not_found" };
105
+ }
106
+ const score = similarityScore(segment, match.text);
107
+ const status =
108
+ score >= 0.6
109
+ ? "aligned"
110
+ : score >= 0.25
111
+ ? "potential_mismatch"
112
+ : "not_found";
113
+ return {
114
+ segment,
115
+ status,
116
+ lawId: match.lawId,
117
+ articleNumber: match.articleNumber,
118
+ lawSnippet: match.text.slice(0, 400),
119
+ score: Number(score.toFixed(3)),
120
+ };
121
+ });
122
+
123
+ const matchedLawIds = Array.from(
124
+ new Set(findings.map((f) => f.lawId).filter(Boolean))
125
+ ) as string[];
126
+
127
+ return { findings, matchedLawIds };
128
+ };
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { StdioJsonRpcServer } from "./mcp.js";
2
+ import { tools, resolveTool } from "./tools.js";
3
+
4
+ const server = new StdioJsonRpcServer();
5
+
6
+ server.register("ping", async () => ({ ok: true }));
7
+
8
+ server.register("tools/list", async () => ({
9
+ tools: tools.map((tool) => ({
10
+ name: tool.name,
11
+ description: tool.description,
12
+ inputSchema: tool.inputSchema,
13
+ outputSchema: tool.outputSchema,
14
+ })),
15
+ }));
16
+
17
+ server.register("tools/call", async (params) => {
18
+ const payload = (params ?? {}) as { name?: unknown; arguments?: unknown };
19
+ if (typeof payload.name !== "string") {
20
+ throw new Error("Tool name is required and must be a string");
21
+ }
22
+ const tool = resolveTool(payload.name);
23
+ if (!tool) {
24
+ throw new Error(`Tool ${payload.name} is not available`);
25
+ }
26
+ const args =
27
+ payload.arguments && typeof payload.arguments === "object"
28
+ ? (payload.arguments as Record<string, unknown>)
29
+ : {};
30
+ const result = await tool.handler(args);
31
+ return { content: result };
32
+ });
33
+
34
+ server.start();
package/src/lawApi.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { fetch } from "undici";
2
+ import { cache } from "./cache.js";
3
+ import { config } from "./config.js";
4
+ import { LawData, LawSearchResponse } from "./types.js";
5
+
6
+ const withTimeout = async <T>(promise: Promise<T>, ms: number) => {
7
+ const controller = new AbortController();
8
+ const timer = setTimeout(() => controller.abort(), ms);
9
+ try {
10
+ return await promise;
11
+ } finally {
12
+ clearTimeout(timer);
13
+ }
14
+ };
15
+
16
+ const request = async <T>(url: string) => {
17
+ const res = await withTimeout(
18
+ fetch(url, {
19
+ headers: {
20
+ "User-Agent": config.userAgent,
21
+ Accept: "application/json",
22
+ },
23
+ }),
24
+ config.httpTimeoutMs
25
+ );
26
+
27
+ if (!res.ok) {
28
+ const body = await res.text();
29
+ throw new Error(`Request failed ${res.status}: ${body}`);
30
+ }
31
+
32
+ const data = (await res.json()) as T;
33
+ return data;
34
+ };
35
+
36
+ export const fetchLawData = async (
37
+ lawId: string,
38
+ revisionDate?: string
39
+ ): Promise<LawData> => {
40
+ const cacheKey = `law:${lawId}:${revisionDate || "latest"}`;
41
+ const cached = cache.get(cacheKey) as LawData | undefined;
42
+ if (cached) return cached;
43
+
44
+ const base = config.apiBase.endsWith("/")
45
+ ? config.apiBase
46
+ : `${config.apiBase}/`;
47
+ const url = new URL(`lawdata/${encodeURIComponent(lawId)}`, base);
48
+ if (revisionDate) url.searchParams.set("revision", revisionDate);
49
+ const data = await request<LawData>(url.toString());
50
+ cache.set(cacheKey, data, config.cacheTtlSeconds);
51
+ return data;
52
+ };
53
+
54
+ export const searchLaws = async (
55
+ keyword: string
56
+ ): Promise<LawSearchResponse> => {
57
+ const base = config.apiBase.endsWith("/")
58
+ ? config.apiBase
59
+ : `${config.apiBase}/`;
60
+ const url = new URL(`lawsearch/${encodeURIComponent(keyword)}`, base);
61
+ return request<LawSearchResponse>(url.toString());
62
+ };