feat: production MCP server with Israeli legislation (multi-source)

Complete production implementation with shell+adapter architecture,
13 MCP tools, SQLite FTS5 search, and multi-source ingestion pipeline.

Ingestion fetches from UCI mirror, UNODC SHERLOC PDFs, and Knesset
mobile PDFs (135 provisions, 33 definitions). 3 acts with full text,
7 acts metadata-only due to gov.il/nevo.co.il access restrictions.
Knesset OData API used for metadata enrichment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mortalus
2026-02-19 20:40:01 +01:00
parent 21aa81d2b0
commit 1e28f8a6b1
41 changed files with 9136 additions and 51 deletions

119
api/health.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import Database from '@ansvar/mcp-sqlite';
import { existsSync, copyFileSync, rmSync, statSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import {
REPOSITORY_URL,
SERVER_NAME,
SERVER_VERSION,
DB_ENV_VAR,
} from '../src/constants.js';
const SOURCE_DB = process.env[DB_ENV_VAR] || join(process.cwd(), 'data', 'database.db');
const TMP_DB = '/tmp/database.db';
const TMP_DB_META = '/tmp/database.db.meta.json';
const STALENESS_THRESHOLD_DAYS = 30;
function getHealthDb(): InstanceType<typeof Database> | null {
try {
if (!existsSync(TMP_DB) && existsSync(SOURCE_DB)) {
rmSync('/tmp/database.db.lock', { recursive: true, force: true });
copyFileSync(SOURCE_DB, TMP_DB);
const stats = statSync(SOURCE_DB);
writeFileSync(
TMP_DB_META,
JSON.stringify({ source_db: SOURCE_DB, source_signature: `${stats.size}:${Math.trunc(stats.mtimeMs)}` }),
'utf-8',
);
}
if (existsSync(TMP_DB)) {
return new Database(TMP_DB, { readonly: true });
}
} catch {
// DB not available
}
return null;
}
function readMeta(db: InstanceType<typeof Database>, key: string): string | null {
try {
const row = db.prepare('SELECT value FROM db_metadata WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value ?? null;
} catch {
return null;
}
}
function safeCount(db: InstanceType<typeof Database>, sql: string): number {
try {
const row = db.prepare(sql).get() as { count: number } | undefined;
return row ? Number(row.count) : 0;
} catch {
return 0;
}
}
export default function handler(req: VercelRequest, res: VercelResponse) {
const url = new URL(req.url ?? '/', `https://${req.headers.host}`);
if (url.pathname === '/version' || url.searchParams.has('version')) {
res.status(200).json({
name: SERVER_NAME,
version: SERVER_VERSION,
node_version: process.version,
transport: ['stdio', 'streamable-http'],
capabilities: ['statutes', 'eu_cross_references'],
tier: 'free',
source_schema_version: '1.0',
repo_url: REPOSITORY_URL,
report_issue_url: `${REPOSITORY_URL}/issues/new?template=data-error.md`,
});
return;
}
const db = getHealthDb();
let dataStatus: 'ok' | 'stale' | 'degraded' = 'degraded';
let builtAt: string | null = null;
let daysSinceBuilt: number | null = null;
let tier: string = 'free';
let schemaVersion: string = 'unknown';
let counts: Record<string, number> = {};
if (db) {
try {
builtAt = readMeta(db, 'built_at');
tier = readMeta(db, 'tier') ?? 'free';
schemaVersion = readMeta(db, 'schema_version') ?? 'unknown';
if (builtAt) {
daysSinceBuilt = Math.floor(
(Date.now() - new Date(builtAt).getTime()) / (1000 * 60 * 60 * 24),
);
dataStatus = daysSinceBuilt > STALENESS_THRESHOLD_DAYS ? 'stale' : 'ok';
}
counts = {
documents: safeCount(db, 'SELECT COUNT(*) as count FROM legal_documents'),
provisions: safeCount(db, 'SELECT COUNT(*) as count FROM legal_provisions'),
};
} finally {
db.close();
}
}
res.status(200).json({
status: dataStatus,
server: SERVER_NAME,
version: SERVER_VERSION,
uptime_seconds: Math.floor(process.uptime()),
data: {
built_at: builtAt,
days_since_built: daysSinceBuilt,
staleness_threshold_days: STALENESS_THRESHOLD_DAYS,
schema_version: schemaVersion,
counts,
},
capabilities: ['statutes', 'eu_cross_references'],
tier,
});
}

145
api/mcp.ts Normal file
View File

@@ -0,0 +1,145 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import Database from '@ansvar/mcp-sqlite';
import { join } from 'path';
import { copyFileSync, existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
import { createHash } from 'crypto';
import { registerTools } from '../src/tools/registry.js';
import {
DB_ENV_VAR,
SERVER_NAME,
SERVER_VERSION,
} from '../src/constants.js';
import type { AboutContext } from '../src/tools/registry.js';
const SOURCE_DB = process.env[DB_ENV_VAR]
|| join(process.cwd(), 'data', 'database.db');
const TMP_DB = '/tmp/database.db';
const TMP_DB_LOCK = '/tmp/database.db.lock';
const TMP_DB_SHM = '/tmp/database.db-shm';
const TMP_DB_WAL = '/tmp/database.db-wal';
const TMP_DB_META = '/tmp/database.db.meta.json';
let db: InstanceType<typeof Database> | null = null;
interface TmpDbMeta {
source_db: string;
source_signature: string;
}
function computeSourceSignature(): string {
const stats = statSync(SOURCE_DB);
return `${stats.size}:${Math.trunc(stats.mtimeMs)}`;
}
function readTmpMeta(): TmpDbMeta | null {
if (!existsSync(TMP_DB_META)) return null;
try {
const parsed = JSON.parse(readFileSync(TMP_DB_META, 'utf-8')) as Partial<TmpDbMeta>;
if (parsed.source_db && parsed.source_signature) {
return { source_db: parsed.source_db, source_signature: parsed.source_signature };
}
} catch {
// Ignore corrupted metadata
}
return null;
}
function clearTmpDbArtifacts() {
rmSync(TMP_DB_LOCK, { recursive: true, force: true });
rmSync(TMP_DB_SHM, { force: true });
rmSync(TMP_DB_WAL, { force: true });
rmSync(TMP_DB, { force: true });
rmSync(TMP_DB_META, { force: true });
}
function ensureTempDbIsFresh() {
const sourceSignature = computeSourceSignature();
const meta = readTmpMeta();
const shouldRefresh =
!existsSync(TMP_DB) || !meta || meta.source_db !== SOURCE_DB || meta.source_signature !== sourceSignature;
if (shouldRefresh) {
clearTmpDbArtifacts();
copyFileSync(SOURCE_DB, TMP_DB);
writeFileSync(TMP_DB_META, JSON.stringify({ source_db: SOURCE_DB, source_signature: sourceSignature }), 'utf-8');
return;
}
rmSync(TMP_DB_LOCK, { recursive: true, force: true });
}
function computeAboutContext(): AboutContext {
let fingerprint = 'unknown';
let dbBuilt = 'unknown';
try {
const buf = readFileSync(SOURCE_DB);
fingerprint = createHash('sha256').update(buf).digest('hex').slice(0, 12);
} catch { /* ignore */ }
try {
const database = getDatabase();
const row = database.prepare("SELECT value FROM db_metadata WHERE key = 'built_at'").get() as { value: string } | undefined;
if (row?.value) dbBuilt = row.value;
} catch { /* ignore */ }
return { version: SERVER_VERSION, fingerprint, dbBuilt };
}
function getDatabase(): InstanceType<typeof Database> {
if (!db) {
ensureTempDbIsFresh();
db = new Database(TMP_DB, { readonly: true });
db.pragma('foreign_keys = ON');
}
return db;
}
export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id');
res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
if (req.method === 'OPTIONS') {
res.status(204).end();
return;
}
if (req.method === 'GET') {
res.status(200).json({
name: SERVER_NAME,
version: SERVER_VERSION,
protocol: 'mcp-streamable-http',
});
return;
}
try {
if (!existsSync(SOURCE_DB)) {
res.status(500).json({ error: `Database not found at ${SOURCE_DB}` });
return;
}
const database = getDatabase();
const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
registerTools(server, database, computeAboutContext());
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error('MCP handler error:', message);
if (!res.headersSent) {
res.status(500).json({ error: message });
}
}
}