@iflow-mcp/ansvar-systems-german-law-mcp
Version:
German legal research MCP server with statutes, case law, and preparatory works from gesetze-im-internet.de
280 lines (269 loc) • 9.85 kB
JavaScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const ADAPTER_INDEX_PATH = "src/adapters/index.ts";
const IMPORT_START_MARKER = "// scaffold-imports-start";
const IMPORT_END_MARKER = "// scaffold-imports-end";
const ARRAY_START_MARKER = "// scaffold-adapters-start";
const ARRAY_END_MARKER = "// scaffold-adapters-end";
async function main() {
const options = parseCliOptions(process.argv.slice(2));
validateCode(options.code);
const adapterPath = `src/adapters/${options.code}.ts`;
const sampleDataPath = `src/sample-data/${options.code}-legislation.ts`;
const testPath = `test/${options.code}.adapter.test.ts`;
await assertNotExists(adapterPath);
await assertNotExists(sampleDataPath);
await assertNotExists(testPath);
const files = [
{
filePath: adapterPath,
content: buildAdapterTemplate(options),
},
{
filePath: sampleDataPath,
content: buildSampleDataTemplate(options),
},
{
filePath: testPath,
content: buildTestTemplate(options),
},
];
if (options.register) {
const registry = await readFile(resolveRoot(ADAPTER_INDEX_PATH), "utf8");
const updated = updateAdapterRegistry(registry, options.code);
files.push({
filePath: ADAPTER_INDEX_PATH,
content: updated,
});
}
if (options.dryRun) {
printDryRun(files.map((entry) => entry.filePath), options);
return;
}
for (const file of files) {
const absolutePath = resolveRoot(file.filePath);
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, file.content, "utf8");
}
process.stdout.write([
`Created template files for "${options.name}" (${options.code}).`,
`- ${adapterPath}`,
`- ${sampleDataPath}`,
`- ${testPath}`,
options.register
? `- Updated ${ADAPTER_INDEX_PATH}`
: `- Skipped adapter registry update (--no-register)`,
"Next steps:",
`1. Replace sample data and citation rules in ${adapterPath}.`,
`2. Run: npm run typecheck && npm test`,
].join("\n") + "\n");
}
function parseCliOptions(argv) {
const flags = parseFlags(argv);
const code = (getStringFlag(flags, "code") ?? "").toLowerCase();
const name = getStringFlag(flags, "name") ?? code.toUpperCase();
const language = getStringFlag(flags, "language") ?? code;
const source = getStringFlag(flags, "source") ?? "official-source";
const register = !flags["no-register"];
const dryRun = Boolean(flags["dry-run"]);
if (!code) {
throw new Error("Missing required flag --code. Example: npm run scaffold:country -- --code de --name Germany --language de --source gesetze-im-internet");
}
return {
code,
name,
language,
source,
register,
dryRun,
};
}
function parseFlags(argv) {
const output = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (!token?.startsWith("--")) {
continue;
}
const key = token.slice(2);
const next = argv[index + 1];
const hasValue = typeof next === "string" && !next.startsWith("--");
if (hasValue) {
output[key] = next;
index += 1;
}
else {
output[key] = true;
}
}
return output;
}
function getStringFlag(flags, key) {
const value = flags[key];
return typeof value === "string" ? value : undefined;
}
function validateCode(code) {
if (!/^[a-z]{2,3}$/.test(code)) {
throw new Error(`Invalid country code "${code}". Use 2-3 lowercase letters, for example "de" or "fra".`);
}
}
async function assertNotExists(relativeFilePath) {
try {
await readFile(resolveRoot(relativeFilePath), "utf8");
throw new Error(`File already exists: ${relativeFilePath}`);
}
catch (error) {
const message = error.message;
if (!message.includes("ENOENT")) {
throw error;
}
}
}
function buildAdapterTemplate(options) {
const variablePrefix = countryVariablePrefix(options.code);
const adapterName = `${variablePrefix}Adapter`;
const sampleConst = `${variablePrefix.toUpperCase()}_LEGISLATION`;
const citationPrefix = options.code.toUpperCase();
return `import { ${sampleConst} } from "../sample-data/${options.code}-legislation.js";
import {
createRegexCitationHandlers,
createStaticIngestionRunner,
getDocumentFromMemory,
searchDocumentsInMemory,
} from "../shell/adapter-kit.js";
import type { CountryAdapter } from "../shell/types.js";
const ${citationPrefix}_CITATION_PATTERN =
/^${citationPrefix}-(?<year>\\\\d{4})-(?<number>\\\\d+)$/i;
const { parseCitation, validateCitation } = createRegexCitationHandlers({
pattern: ${citationPrefix}_CITATION_PATTERN,
reason:
"Citation does not match expected format (${citationPrefix}-YYYY-NNN). Update this rule for ${options.name}.",
normalize: ({ year, number }) => \`${citationPrefix}-\${year}-\${number}\`,
});
const runIngestion = createStaticIngestionRunner({
defaultSource: "${options.source}",
count: ${sampleConst}.length,
});
export const ${adapterName}: CountryAdapter = {
country: {
code: "${options.code}",
name: "${escapeString(options.name)}",
defaultLanguage: "${options.language}",
sources: ["${options.source}"],
},
capabilities: {
documents: true,
citations: true,
ingestion: true,
},
async searchDocuments(request) {
return searchDocumentsInMemory(${sampleConst}, request);
},
async getDocument(id) {
return getDocumentFromMemory(${sampleConst}, id);
},
parseCitation,
validateCitation,
runIngestion,
};
`;
}
function buildSampleDataTemplate(options) {
const sampleConst = `${countryVariablePrefix(options.code).toUpperCase()}_LEGISLATION`;
const citationPrefix = options.code.toUpperCase();
return `import type { LawDocument } from "../shell/types.js";
export const ${sampleConst}: LawDocument[] = [
{
id: "${options.code}-sample-001",
country: "${options.code}",
kind: "statute",
title: "${escapeString(options.name)} Sample Act",
citation: "${citationPrefix}-2020-1",
sourceUrl: "https://example.org/${options.code}/sample-act",
effectiveDate: "2020-01-01",
textSnippet: "Replace this with real text from your official source.",
metadata: {
source: "${options.source}",
template: true,
},
},
];
`;
}
function buildTestTemplate(options) {
const citationPrefix = options.code.toUpperCase();
return `import assert from "node:assert/strict";
import test from "node:test";
import { BUILTIN_ADAPTERS } from "../src/adapters/index.js";
import { LawMcpShell } from "../src/shell/shell.js";
const shell = LawMcpShell.fromAdapters(BUILTIN_ADAPTERS);
test("${options.code} adapter is discoverable", async () => {
const result = await shell.handleToolCall({
name: "law.describe_country",
arguments: { country: "${options.code}" },
});
assert.equal(result.ok, true);
assert.equal(
(result.data as { country: { code: string } }).country.code,
"${options.code}",
);
});
test("${options.code} citation validation works for template format", async () => {
const result = await shell.handleToolCall({
name: "law.validate_citation",
arguments: { country: "${options.code}", citation: "${citationPrefix}-2020-1" },
});
assert.equal(result.ok, true);
assert.equal((result.data as { valid: boolean }).valid, true);
});
`;
}
function updateAdapterRegistry(content, code) {
const variablePrefix = countryVariablePrefix(code);
const adapterName = `${variablePrefix}Adapter`;
const importLine = `import { ${adapterName} } from "./${code}.js";`;
const adapterLine = ` ${adapterName},`;
const updatedImports = upsertBetweenMarkers(content, IMPORT_START_MARKER, IMPORT_END_MARKER, importLine);
return upsertBetweenMarkers(updatedImports, ARRAY_START_MARKER, ARRAY_END_MARKER, adapterLine);
}
function upsertBetweenMarkers(content, startMarker, endMarker, newLine) {
const lines = content.split("\n");
const startIndex = lines.findIndex((line) => line.includes(startMarker));
const endIndex = lines.findIndex((line) => line.includes(endMarker));
if (startIndex < 0 || endIndex < 0 || startIndex >= endIndex) {
throw new Error(`Could not find marker block: ${startMarker} ... ${endMarker}`);
}
const existing = lines
.slice(startIndex + 1, endIndex)
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0);
const deduped = new Set([...existing, newLine]);
const sorted = [...deduped].sort((a, b) => a.localeCompare(b));
const replacement = [
...lines.slice(0, startIndex + 1),
...sorted,
...lines.slice(endIndex),
];
return replacement.join("\n");
}
function countryVariablePrefix(code) {
return `${code.toLowerCase()}${code.toLowerCase().length === 2 ? "" : "Country"}`;
}
function escapeString(value) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function resolveRoot(relativePath) {
return path.resolve(process.cwd(), relativePath);
}
function printDryRun(paths, options) {
process.stdout.write([
"Dry run only. No files were written.",
`Country: ${options.name} (${options.code})`,
...paths.map((filePath) => `- ${filePath}`),
].join("\n") + "\n");
}
void main().catch((error) => {
const message = error instanceof Error ? error.message : "Unknown error";
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});