UNPKG

@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

565 lines (564 loc) • 24 kB
/** * Premium tool support for law MCP servers. * * Injected by build-all.sh into each MCP's src/ directory. * Detects premium tables (case_law, preparatory_works, agency_guidance) * and their FTS5 indexes at runtime, then wraps the server's existing * ListTools and CallTool handlers to expose search tools for any tables * that are present. * * Safe to inject into any law MCP — if no premium tables exist, this * function is a no-op. */ // --------------------------------------------------------------------------- // FTS query building (self-contained, no external dependency) // --------------------------------------------------------------------------- const EXPLICIT_FTS_RE = /["*():^]|\bAND\b|\bOR\b|\bNOT\b/iu; function sanitizeToken(token) { return token.replace(/[^\p{L}\p{N}_]/gu, ''); } function extractTokens(query) { const matches = query.normalize('NFC').match(/[\p{L}\p{N}_]+/gu) ?? []; return matches.map(sanitizeToken).filter(t => t.length > 1); } function buildFtsVariants(query) { const trimmed = query.trim(); if (!trimmed) return { primary: '' }; if (EXPLICIT_FTS_RE.test(trimmed)) { // User is writing explicit FTS5 syntax — pass through with minimal escaping. return { primary: trimmed.replace(/[()^:]/g, ch => `"${ch}"`) }; } const tokens = extractTokens(trimmed); if (tokens.length === 0) { return { primary: trimmed.replace(/[()^:]/g, ch => `"${ch}"`) }; } const primary = tokens.map(t => `${t}*`).join(' '); if (tokens.length === 1) return { primary }; return { primary, fallback: tokens.map(t => `${t}*`).join(' OR ') }; } function getTableColumns(db, table) { try { const rows = db.prepare(`PRAGMA table_info(${table})`).all(); return new Set(rows.map(r => r.name)); } catch { return new Set(); } } function detectPremiumTables(db) { const tables = new Set(); try { const rows = db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view')").all(); for (const r of rows) tables.add(r.name); } catch { return { caps: { caseLaw: false, preparatoryWorks: false, agencyGuidance: false, laenderStatutes: false }, columns: { caseLaw: new Set(), preparatoryWorks: new Set(), agencyGuidance: new Set(), laenderStatutes: new Set(), laenderProvisions: new Set() }, }; } const caps = { caseLaw: tables.has('case_law') && tables.has('case_law_fts'), preparatoryWorks: tables.has('preparatory_works') && tables.has('preparatory_works_fts'), agencyGuidance: tables.has('agency_guidance') && tables.has('agency_guidance_fts'), laenderStatutes: tables.has('laender_statutes') && tables.has('laender_statutes_fts'), }; return { caps, columns: { caseLaw: caps.caseLaw ? getTableColumns(db, 'case_law') : new Set(), preparatoryWorks: caps.preparatoryWorks ? getTableColumns(db, 'preparatory_works') : new Set(), agencyGuidance: caps.agencyGuidance ? getTableColumns(db, 'agency_guidance') : new Set(), laenderStatutes: caps.laenderStatutes ? getTableColumns(db, 'laender_statutes') : new Set(), laenderProvisions: caps.laenderStatutes ? getTableColumns(db, 'laender_provisions') : new Set(), }, }; } function caseLawToolDef() { return { name: 'search_case_law', description: 'Full-text search across court decisions. Returns document_id, title, court, case_number, ' + 'decision_date, snippet, and keywords. Ranked by BM25 relevance. ' + 'Do NOT use for statute text — use search_legislation or get_provision instead.', inputSchema: { type: 'object', properties: { query: { type: 'string', minLength: 1, description: 'Search query (supports FTS5 syntax)' }, court: { type: 'string', description: 'Filter by court name' }, date_from: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'Start date (YYYY-MM-DD)' }, date_to: { type: 'string', pattern: '^\\d{4}-\\d{2}-\\d{2}$', description: 'End date (YYYY-MM-DD)' }, limit: { type: 'number', default: 10, minimum: 1, maximum: 50, description: 'Max results (default 10)' }, }, required: ['query'], }, }; } function prepWorksToolDef() { return { name: 'search_preparatory_works', description: 'Full-text search across preparatory works (legislative history, bills, committee reports). ' + 'Returns document_id, title, type, date, and snippet. Ranked by BM25 relevance.', inputSchema: { type: 'object', properties: { query: { type: 'string', minLength: 1, description: 'Search query (supports FTS5 syntax)' }, type: { type: 'string', description: 'Filter by document type' }, limit: { type: 'number', default: 10, minimum: 1, maximum: 50, description: 'Max results (default 10)' }, }, required: ['query'], }, }; } function agencyGuidanceToolDef() { return { name: 'search_agency_guidance', description: 'Full-text search across agency guidance documents (circulars, opinions, rulings). ' + 'Returns document_id, title, agency, date, and snippet. Ranked by BM25 relevance.', inputSchema: { type: 'object', properties: { query: { type: 'string', minLength: 1, description: 'Search query (supports FTS5 syntax)' }, agency: { type: 'string', description: 'Filter by issuing agency' }, limit: { type: 'number', default: 10, minimum: 1, maximum: 50, description: 'Max results (default 10)' }, }, required: ['query'], }, }; } function laenderSearchToolDef() { return { name: 'search_laender_legislation', description: 'Full-text search across German state (Laender) legislation. ' + 'Returns statute title, state, type, date, and matching provision snippets. ' + 'Use state_code filter for a specific Bundesland (e.g. "BY" for Bayern, "NW" for NRW). ' + 'Do NOT use for federal law — use search_legislation or get_provision instead.', inputSchema: { type: 'object', properties: { query: { type: 'string', minLength: 1, description: 'Search query (supports FTS5 syntax)' }, state_code: { type: 'string', description: 'Filter by state: BW, BY, BE, BB, HB, HH, HE, MV, NI, NW, RP, SL, SN, ST, SH, TH', }, type: { type: 'string', description: 'Filter by type: Gesetz, Verordnung, Verwaltungsvorschrift' }, limit: { type: 'number', default: 10, minimum: 1, maximum: 50, description: 'Max results (default 10)' }, }, required: ['query'], }, }; } function laenderGetProvisionToolDef() { return { name: 'get_laender_provision', description: 'Get a specific provision from a German state law. ' + 'Provide the statute_id and optionally a section_number. ' + 'Use search_laender_legislation first to find the statute_id.', inputSchema: { type: 'object', properties: { statute_id: { type: 'string', description: 'The statute ID from search results' }, section_number: { type: 'string', description: 'Section/paragraph number (e.g. "1", "2a"). Omit to get all sections.' }, }, required: ['statute_id'], }, }; } function laenderListStatesToolDef() { return { name: 'list_laender_states', description: 'List all available German states (Bundeslaender) with counts of statutes and provisions in the database.', inputSchema: { type: 'object', properties: {}, }, }; } // --------------------------------------------------------------------------- // Query execution // --------------------------------------------------------------------------- const DEFAULT_LIMIT = 10; const MAX_LIMIT = 50; function clampLimit(raw) { return Math.min(Math.max(raw ?? DEFAULT_LIMIT, 1), MAX_LIMIT); } function runSearchCaseLaw(db, args, cols) { const query = String(args.query ?? '').trim(); if (!query) return { results: [], count: 0 }; const limit = clampLimit(args.limit); const variants = buildFtsVariants(query); // Resolve document_id column (varies across jurisdictions) const docIdCol = cols.has('document_id') ? 'cl.document_id' : cols.has('case_id') ? 'cl.case_id' : cols.has('document_uri') ? 'cl.document_uri' : cols.has('neutral_citation') ? 'cl.neutral_citation' : "cl.id"; // Build SELECT columns based on what actually exists in this DB const selects = [ `${docIdCol} AS document_id`, cols.has('title') ? 'cl.title' : `${docIdCol} AS title`, cols.has('court') ? 'cl.court' : "NULL AS court", cols.has('case_number') ? 'cl.case_number' : "NULL AS case_number", cols.has('decision_date') ? 'cl.decision_date' : cols.has('date_decided') ? 'cl.date_decided AS decision_date' : "NULL AS decision_date", "snippet(case_law_fts, 0, '>>>', '<<<', '...', 32) AS snippet", cols.has('keywords') ? 'cl.keywords' : "NULL AS keywords", 'bm25(case_law_fts) AS relevance', ]; let sql = ` SELECT ${selects.join(',\n ')} FROM case_law_fts JOIN case_law cl ON cl.id = case_law_fts.rowid WHERE case_law_fts MATCH ? `; const params = []; if (args.court && cols.has('court')) { sql += ' AND cl.court = ?'; params.push(String(args.court)); } const dateCol = cols.has('decision_date') ? 'cl.decision_date' : cols.has('date_decided') ? 'cl.date_decided' : null; if (args.date_from && dateCol) { sql += ` AND ${dateCol} >= ?`; params.push(String(args.date_from)); } if (args.date_to && dateCol) { sql += ` AND ${dateCol} <= ?`; params.push(String(args.date_to)); } sql += ' ORDER BY relevance LIMIT ?'; params.push(limit); const run = (fts) => db.prepare(sql).all(fts, ...params); let results = run(variants.primary); if (results.length === 0 && variants.fallback) { results = run(variants.fallback); } return { results, count: results.length }; } function runSearchPrepWorks(db, args, cols) { const query = String(args.query ?? '').trim(); if (!query) return { results: [], count: 0 }; const limit = clampLimit(args.limit); const variants = buildFtsVariants(query); // Build date expression from available columns let dateExpr = "NULL AS date"; if (cols.has('date_introduced') && cols.has('date_enacted')) { dateExpr = "COALESCE(pw.date_introduced, pw.date_enacted) AS date"; } else if (cols.has('date_introduced')) { dateExpr = "pw.date_introduced AS date"; } else if (cols.has('date_enacted')) { dateExpr = "pw.date_enacted AS date"; } else if (cols.has('publication_date')) { dateExpr = "pw.publication_date AS date"; } else if (cols.has('date')) { dateExpr = "pw.date AS date"; } // Schema-adaptive document_id: fall back to dip_id, id const docIdExpr = cols.has('document_id') ? 'pw.document_id' : cols.has('dip_id') ? 'pw.dip_id AS document_id' : 'pw.id AS document_id'; const titleFallback = cols.has('document_id') ? 'pw.document_id' : 'pw.id'; const selects = [ docIdExpr, cols.has('title') ? 'pw.title' : `${titleFallback} AS title`, cols.has('type') ? 'pw.type' : cols.has('work_type') ? 'pw.work_type AS type' : "NULL AS type", dateExpr, "snippet(preparatory_works_fts, 0, '>>>', '<<<', '...', 32) AS snippet", 'bm25(preparatory_works_fts) AS relevance', ]; let sql = ` SELECT ${selects.join(',\n ')} FROM preparatory_works_fts JOIN preparatory_works pw ON pw.id = preparatory_works_fts.rowid WHERE preparatory_works_fts MATCH ? `; const params = []; if (args.type && cols.has('type')) { sql += ' AND pw.type = ?'; params.push(String(args.type)); } sql += ' ORDER BY relevance LIMIT ?'; params.push(limit); const run = (fts) => db.prepare(sql).all(fts, ...params); let results = run(variants.primary); if (results.length === 0 && variants.fallback) { results = run(variants.fallback); } return { results, count: results.length }; } function runSearchAgencyGuidance(db, args) { const query = String(args.query ?? '').trim(); if (!query) return { results: [], count: 0 }; const limit = clampLimit(args.limit); const variants = buildFtsVariants(query); let sql = ` SELECT ag.document_id, ag.title, ag.agency, ag.issued_date AS date, snippet(agency_guidance_fts, 0, '>>>', '<<<', '...', 32) AS snippet, bm25(agency_guidance_fts) AS relevance FROM agency_guidance_fts JOIN agency_guidance ag ON ag.id = agency_guidance_fts.rowid WHERE agency_guidance_fts MATCH ? `; const params = []; if (args.agency) { sql += ' AND ag.agency = ?'; params.push(String(args.agency)); } sql += ' ORDER BY relevance LIMIT ?'; params.push(limit); const run = (fts) => db.prepare(sql).all(fts, ...params); let results = run(variants.primary); if (results.length === 0 && variants.fallback) { results = run(variants.fallback); } return { results, count: results.length }; } function runSearchLaender(db, args) { const query = String(args.query ?? '').trim(); if (!query) return { results: [], count: 0 }; const limit = clampLimit(args.limit); const variants = buildFtsVariants(query); // Search statutes by default, join provisions for snippets let sql = ` SELECT ls.id AS statute_id, ls.title, ls.short_title, ls.state_code, ls.state_name, ls.type, ls.date_enacted, ls.source_url, snippet(laender_statutes_fts, 0, '>>>', '<<<', '...', 32) AS snippet, bm25(laender_statutes_fts) AS relevance FROM laender_statutes_fts JOIN laender_statutes ls ON ls.rowid = laender_statutes_fts.rowid WHERE laender_statutes_fts MATCH ? `; const params = []; if (args.state_code) { sql += ' AND ls.state_code = ?'; params.push(String(args.state_code).toUpperCase()); } if (args.type) { sql += ' AND ls.type = ?'; params.push(String(args.type)); } sql += ' ORDER BY relevance LIMIT ?'; params.push(limit); const run = (fts) => db.prepare(sql).all(fts, ...params); let results = run(variants.primary); if (results.length === 0 && variants.fallback) { results = run(variants.fallback); } // If no statute matches, try provision-level search if (results.length === 0) { let provSql = ` SELECT lp.statute_id, ls.title AS statute_title, ls.state_code, ls.state_name, lp.section_number, lp.title AS section_title, snippet(laender_provisions_fts, 1, '>>>', '<<<', '...', 32) AS snippet, bm25(laender_provisions_fts) AS relevance FROM laender_provisions_fts JOIN laender_provisions lp ON lp.rowid = laender_provisions_fts.rowid JOIN laender_statutes ls ON ls.id = lp.statute_id WHERE laender_provisions_fts MATCH ? `; const provParams = []; if (args.state_code) { provSql += ' AND lp.state_code = ?'; provParams.push(String(args.state_code).toUpperCase()); } provSql += ' ORDER BY relevance LIMIT ?'; provParams.push(limit); const runProv = (fts) => db.prepare(provSql).all(fts, ...provParams); results = runProv(variants.primary); if (results.length === 0 && variants.fallback) { results = runProv(variants.fallback); } } return { results, count: results.length }; } function runGetLaenderProvision(db, args) { const statuteId = String(args.statute_id ?? '').trim(); if (!statuteId) return { error: 'statute_id is required' }; const sectionNumber = args.section_number ? String(args.section_number).trim() : null; if (sectionNumber) { const row = db.prepare(` SELECT lp.id, lp.statute_id, lp.state_code, lp.section_number, lp.title, lp.content, lp.order_index, ls.title AS statute_title, ls.state_name FROM laender_provisions lp JOIN laender_statutes ls ON ls.id = lp.statute_id WHERE lp.statute_id = ? AND lp.section_number = ? `).get(statuteId, sectionNumber); if (!row) return { error: `Provision ${sectionNumber} not found in ${statuteId}` }; return row; } // Return all provisions for this statute const rows = db.prepare(` SELECT lp.id, lp.section_number, lp.title, lp.content, lp.order_index FROM laender_provisions lp WHERE lp.statute_id = ? ORDER BY lp.order_index `).all(statuteId); const statute = db.prepare(` SELECT id, state_code, state_name, title, short_title, type, date_enacted, source_url FROM laender_statutes WHERE id = ? `).get(statuteId); return { statute: statute ?? null, provisions: rows, count: rows.length }; } function runListLaenderStates(db) { const rows = db.prepare(` SELECT state_code, state_name, COUNT(*) AS statute_count, (SELECT COUNT(*) FROM laender_provisions lp WHERE lp.state_code = ls.state_code) AS provision_count FROM laender_statutes ls GROUP BY state_code, state_name ORDER BY state_name `).all(); return { states: rows, count: rows.length }; } // --------------------------------------------------------------------------- // Public API — wrap a Server with premium tools // --------------------------------------------------------------------------- /** * Detect premium tables in the database and, if any exist, wrap the server's * ListTools and CallTool handlers to include premium search tools. * * This is safe to call on any law MCP server. If no premium tables exist the * function returns immediately without modifying the server. */ /** * Return premium tool definitions and handlers for direct integration. * Use this instead of wrapWithPremiumTools when the server has a custom * http-server.ts that registers its own handlers. */ export function getPremiumTools(db) { const { caps, columns } = detectPremiumTables(db); if (!caps.caseLaw && !caps.preparatoryWorks && !caps.agencyGuidance && !caps.laenderStatutes) { return null; } const tools = []; const handlers = new Map(); if (caps.caseLaw) { tools.push(caseLawToolDef()); handlers.set('search_case_law', (args) => runSearchCaseLaw(db, args, columns.caseLaw)); } if (caps.preparatoryWorks) { tools.push(prepWorksToolDef()); handlers.set('search_preparatory_works', (args) => runSearchPrepWorks(db, args, columns.preparatoryWorks)); } if (caps.agencyGuidance) { tools.push(agencyGuidanceToolDef()); handlers.set('search_agency_guidance', (args) => runSearchAgencyGuidance(db, args)); } if (caps.laenderStatutes) { tools.push(laenderSearchToolDef()); handlers.set('search_laender_legislation', (args) => runSearchLaender(db, args)); tools.push(laenderGetProvisionToolDef()); handlers.set('get_laender_provision', (args) => runGetLaenderProvision(db, args)); tools.push(laenderListStatesToolDef()); handlers.set('list_laender_states', () => runListLaenderStates(db)); } const names = tools.map(t => t.name).join(', '); console.error(`[premium-tools] Available ${tools.length} premium tools: ${names}`); return { tools, handlers }; } export function wrapWithPremiumTools(server, db) { const { caps, columns } = detectPremiumTables(db); // Nothing to add — leave the server untouched. if (!caps.caseLaw && !caps.preparatoryWorks && !caps.agencyGuidance && !caps.laenderStatutes) { return; } // Build the set of premium tool definitions and their executors. const premiumTools = []; const premiumHandlers = new Map(); if (caps.caseLaw) { premiumTools.push(caseLawToolDef()); premiumHandlers.set('search_case_law', (args) => runSearchCaseLaw(db, args, columns.caseLaw)); } if (caps.preparatoryWorks) { premiumTools.push(prepWorksToolDef()); premiumHandlers.set('search_preparatory_works', (args) => runSearchPrepWorks(db, args, columns.preparatoryWorks)); } if (caps.agencyGuidance) { premiumTools.push(agencyGuidanceToolDef()); premiumHandlers.set('search_agency_guidance', (args) => runSearchAgencyGuidance(db, args)); } if (caps.laenderStatutes) { premiumTools.push(laenderSearchToolDef()); premiumHandlers.set('search_laender_legislation', (args) => runSearchLaender(db, args)); premiumTools.push(laenderGetProvisionToolDef()); premiumHandlers.set('get_laender_provision', (args) => runGetLaenderProvision(db, args)); premiumTools.push(laenderListStatesToolDef()); premiumHandlers.set('list_laender_states', () => runListLaenderStates(db)); } const premiumToolNames = new Set(premiumHandlers.keys()); // Grab the existing handlers so we can delegate to them. // eslint-disable-next-line @typescript-eslint/no-explicit-any const srv = server; const existingListTools = srv._requestHandlers?.get('tools/list'); const existingCallTool = srv._requestHandlers?.get('tools/call'); if (!existingListTools || !existingCallTool) { // Server hasn't registered tool handlers yet — cannot wrap. console.error('[premium-tools] No existing tool handlers found; skipping premium injection.'); return; } // Replace ListTools: base tools + premium tools (deduplicated by name). srv._requestHandlers.set('tools/list', async (request, extra) => { const baseResult = await existingListTools(request, extra); const baseTools = baseResult?.tools ?? []; // Remove any base tools that share a name with a premium tool (premium wins). const filtered = baseTools.filter((t) => !premiumToolNames.has(t.name)); return { tools: [...filtered, ...premiumTools] }; }); // Replace CallTool: handle premium tool names, delegate the rest. srv._requestHandlers.set('tools/call', async (request, extra) => { const params = request?.params; const toolName = params?.name ?? ''; const args = params?.arguments ?? {}; const handler = premiumHandlers.get(toolName); if (!handler) { // Not a premium tool — delegate to the base handler. return existingCallTool(request, extra); } try { const result = handler(args); return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { content: [{ type: 'text', text: `Error executing ${toolName}: ${message}` }], isError: true, }; } }); const names = premiumTools.map(t => t.name).join(', '); console.error(`[premium-tools] Injected ${premiumTools.length} premium tools: ${names}`); }