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:
64
src/capabilities.ts
Normal file
64
src/capabilities.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Runtime capability detection for Israel Law MCP.
|
||||
* Detects which database tables are available to enable/disable features.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
|
||||
export type Capability =
|
||||
| 'core_legislation'
|
||||
| 'eu_references'
|
||||
| 'case_law'
|
||||
| 'preparatory_works';
|
||||
|
||||
const TABLE_MAP: Record<Capability, string[]> = {
|
||||
core_legislation: ['legal_documents', 'legal_provisions', 'provisions_fts'],
|
||||
eu_references: ['eu_documents', 'eu_references'],
|
||||
case_law: ['case_law'],
|
||||
preparatory_works: ['preparatory_works'],
|
||||
};
|
||||
|
||||
export function detectCapabilities(db: InstanceType<typeof Database>): Set<Capability> {
|
||||
const caps = new Set<Capability>();
|
||||
const tables = new Set(
|
||||
(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[])
|
||||
.map(r => r.name)
|
||||
);
|
||||
|
||||
for (const [cap, required] of Object.entries(TABLE_MAP)) {
|
||||
if (required.every(t => tables.has(t))) {
|
||||
caps.add(cap as Capability);
|
||||
}
|
||||
}
|
||||
|
||||
return caps;
|
||||
}
|
||||
|
||||
export interface DbMetadata {
|
||||
tier: string;
|
||||
schema_version: string;
|
||||
built_at?: string;
|
||||
builder?: string;
|
||||
}
|
||||
|
||||
export function readDbMetadata(db: InstanceType<typeof Database>): DbMetadata {
|
||||
const meta: Record<string, string> = {};
|
||||
try {
|
||||
const rows = db.prepare('SELECT key, value FROM db_metadata').all() as { key: string; value: string }[];
|
||||
for (const row of rows) {
|
||||
meta[row.key] = row.value;
|
||||
}
|
||||
} catch {
|
||||
// db_metadata table may not exist
|
||||
}
|
||||
return {
|
||||
tier: meta.tier ?? 'free',
|
||||
schema_version: meta.schema_version ?? '1.0',
|
||||
built_at: meta.built_at,
|
||||
builder: meta.builder,
|
||||
};
|
||||
}
|
||||
|
||||
export function upgradeMessage(feature: string): string {
|
||||
return `The "${feature}" feature requires a professional-tier database. Contact hello@ansvar.ai for access.`;
|
||||
}
|
||||
6
src/constants.ts
Normal file
6
src/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const SERVER_NAME = 'israel-law-mcp';
|
||||
export const SERVER_VERSION = '1.0.0';
|
||||
export const SERVER_LABEL = 'Israel Law MCP';
|
||||
export const PACKAGE_NAME = '@ansvar/israel-law-mcp';
|
||||
export const REPOSITORY_URL = 'https://github.com/Ansvar-Systems/israel-law-mcp';
|
||||
export const DB_ENV_VAR = 'ISRAEL_LAW_DB_PATH';
|
||||
102
src/index.ts
Normal file
102
src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Israel Law MCP Server -- stdio entry point.
|
||||
*
|
||||
* Provides Israeli legislation search via Model Context Protocol.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import Database from '@ansvar/mcp-sqlite';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createHash } from 'crypto';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
import { registerTools, type AboutContext } from './tools/registry.js';
|
||||
import { detectCapabilities, readDbMetadata } from './capabilities.js';
|
||||
import {
|
||||
DB_ENV_VAR,
|
||||
SERVER_NAME,
|
||||
SERVER_VERSION,
|
||||
} from './constants.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function resolveDbPath(): string {
|
||||
if (process.env[DB_ENV_VAR]) {
|
||||
return process.env[DB_ENV_VAR];
|
||||
}
|
||||
return join(__dirname, '..', 'data', 'database.db');
|
||||
}
|
||||
|
||||
let db: InstanceType<typeof Database> | null = null;
|
||||
|
||||
function getDb(): InstanceType<typeof Database> {
|
||||
if (!db) {
|
||||
const dbPath = resolveDbPath();
|
||||
db = new Database(dbPath, { readonly: true });
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const caps = detectCapabilities(db);
|
||||
const meta = readDbMetadata(db);
|
||||
console.error(`[${SERVER_NAME}] DB opened: tier=${meta.tier}, caps=[${[...caps].join(',')}]`);
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function computeAboutContext(): AboutContext {
|
||||
const dbPath = resolveDbPath();
|
||||
let fingerprint = 'unknown';
|
||||
let dbBuilt = 'unknown';
|
||||
|
||||
try {
|
||||
const buf = readFileSync(dbPath);
|
||||
fingerprint = createHash('sha256').update(buf).digest('hex').slice(0, 12);
|
||||
} catch {
|
||||
// DB might not exist in dev
|
||||
}
|
||||
|
||||
try {
|
||||
const database = getDb();
|
||||
const row = database.prepare("SELECT value FROM db_metadata WHERE key = 'built_at'").get() as { value: string } | undefined;
|
||||
if (row) dbBuilt = row.value;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return { version: SERVER_VERSION, fingerprint, dbBuilt };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const database = getDb();
|
||||
const aboutContext = computeAboutContext();
|
||||
|
||||
const server = new Server(
|
||||
{ name: SERVER_NAME, version: SERVER_VERSION },
|
||||
{ capabilities: { tools: {} } }
|
||||
);
|
||||
|
||||
registerTools(server, database, aboutContext);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
console.error(`[${SERVER_NAME}] Server running on stdio`);
|
||||
|
||||
const cleanup = () => {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`[${SERVER_NAME}] Fatal error:`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
55
src/tools/about.ts
Normal file
55
src/tools/about.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* about -- Server metadata, dataset statistics, and provenance.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { detectCapabilities, readDbMetadata } from '../capabilities.js';
|
||||
import { SERVER_NAME, SERVER_VERSION, REPOSITORY_URL } from '../constants.js';
|
||||
|
||||
export interface AboutContext {
|
||||
version: string;
|
||||
fingerprint: string;
|
||||
dbBuilt: string;
|
||||
}
|
||||
|
||||
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 function getAbout(db: InstanceType<typeof Database>, context: AboutContext) {
|
||||
const caps = detectCapabilities(db);
|
||||
const meta = readDbMetadata(db);
|
||||
|
||||
return {
|
||||
server: SERVER_NAME,
|
||||
version: context.version,
|
||||
repository: REPOSITORY_URL,
|
||||
database: {
|
||||
fingerprint: context.fingerprint,
|
||||
built_at: context.dbBuilt,
|
||||
tier: meta.tier,
|
||||
schema_version: meta.schema_version,
|
||||
capabilities: [...caps],
|
||||
},
|
||||
statistics: {
|
||||
documents: safeCount(db, 'SELECT COUNT(*) as count FROM legal_documents'),
|
||||
provisions: safeCount(db, 'SELECT COUNT(*) as count FROM legal_provisions'),
|
||||
definitions: safeCount(db, 'SELECT COUNT(*) as count FROM definitions'),
|
||||
eu_documents: safeCount(db, 'SELECT COUNT(*) as count FROM eu_documents'),
|
||||
eu_references: safeCount(db, 'SELECT COUNT(*) as count FROM eu_references'),
|
||||
},
|
||||
data_source: {
|
||||
name: 'Knesset Legislation Database',
|
||||
authority: 'The Knesset (Israeli Parliament)',
|
||||
url: 'https://main.knesset.gov.il/Activity/Legislation',
|
||||
license: 'Government Open Data',
|
||||
jurisdiction: 'IL',
|
||||
languages: ['he', 'en'],
|
||||
},
|
||||
};
|
||||
}
|
||||
72
src/tools/build-legal-stance.ts
Normal file
72
src/tools/build-legal-stance.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* build_legal_stance -- Build a comprehensive set of citations for a legal question.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { buildFtsQueryVariants, sanitizeFtsInput } from '../utils/fts-query.js';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface BuildLegalStanceInput {
|
||||
query: string;
|
||||
document_id?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface LegalStanceResult {
|
||||
document_id: string;
|
||||
document_title: string;
|
||||
provision_ref: string;
|
||||
section: string;
|
||||
title: string | null;
|
||||
snippet: string;
|
||||
relevance: number;
|
||||
}
|
||||
|
||||
export async function buildLegalStance(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: BuildLegalStanceInput,
|
||||
): Promise<ToolResponse<LegalStanceResult[]>> {
|
||||
if (!input.query || input.query.trim().length === 0) {
|
||||
return { results: [], _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(input.limit ?? 5, 1), 20);
|
||||
const queryVariants = buildFtsQueryVariants(sanitizeFtsInput(input.query));
|
||||
|
||||
for (const ftsQuery of queryVariants) {
|
||||
let sql = `
|
||||
SELECT
|
||||
lp.document_id,
|
||||
ld.title as document_title,
|
||||
lp.provision_ref,
|
||||
lp.section,
|
||||
lp.title,
|
||||
snippet(provisions_fts, 0, '>>>', '<<<', '...', 48) as snippet,
|
||||
bm25(provisions_fts) as relevance
|
||||
FROM provisions_fts
|
||||
JOIN legal_provisions lp ON lp.id = provisions_fts.rowid
|
||||
JOIN legal_documents ld ON ld.id = lp.document_id
|
||||
WHERE provisions_fts MATCH ?
|
||||
`;
|
||||
const params: (string | number)[] = [ftsQuery];
|
||||
|
||||
if (input.document_id) {
|
||||
sql += ' AND lp.document_id = ?';
|
||||
params.push(input.document_id);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY relevance LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
try {
|
||||
const rows = db.prepare(sql).all(...params) as LegalStanceResult[];
|
||||
if (rows.length > 0) {
|
||||
return { results: rows, _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { results: [], _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
71
src/tools/check-currency.ts
Normal file
71
src/tools/check-currency.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* check_currency -- Check whether an Israeli statute is currently in force.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { resolveDocumentId } from '../utils/statute-id.js';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface CheckCurrencyInput {
|
||||
document_id: string;
|
||||
provision_ref?: string;
|
||||
as_of_date?: string;
|
||||
}
|
||||
|
||||
export interface CheckCurrencyResult {
|
||||
document_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
issued_date: string | null;
|
||||
in_force_date: string | null;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export async function checkCurrency(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: CheckCurrencyInput,
|
||||
): Promise<ToolResponse<CheckCurrencyResult>> {
|
||||
const resolvedId = resolveDocumentId(db, input.document_id);
|
||||
if (!resolvedId) {
|
||||
return {
|
||||
results: {
|
||||
document_id: input.document_id,
|
||||
title: 'Unknown',
|
||||
status: 'not_found',
|
||||
issued_date: null,
|
||||
in_force_date: null,
|
||||
warnings: [`Document not found: "${input.document_id}"`],
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
|
||||
const doc = db.prepare(
|
||||
'SELECT id, title, status, issued_date, in_force_date FROM legal_documents WHERE id = ?'
|
||||
).get(resolvedId) as {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
issued_date: string | null;
|
||||
in_force_date: string | null;
|
||||
};
|
||||
|
||||
const warnings: string[] = [];
|
||||
if (doc.status === 'repealed') {
|
||||
warnings.push('This statute has been repealed and is no longer in force.');
|
||||
} else if (doc.status === 'not_yet_in_force') {
|
||||
warnings.push('This statute has not yet entered into force.');
|
||||
}
|
||||
|
||||
return {
|
||||
results: {
|
||||
document_id: doc.id,
|
||||
title: doc.title,
|
||||
status: doc.status,
|
||||
issued_date: doc.issued_date,
|
||||
in_force_date: doc.in_force_date,
|
||||
warnings,
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
55
src/tools/format-citation.ts
Normal file
55
src/tools/format-citation.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* format_citation -- Format an Israeli legal citation per standard conventions.
|
||||
*
|
||||
* Formats:
|
||||
* - "full": "Section N, [Law Name Year]"
|
||||
* - "short": "Section N, [Law Name]"
|
||||
* - "pinpoint": "\u00a7N"
|
||||
*/
|
||||
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
|
||||
export interface FormatCitationInput {
|
||||
citation: string;
|
||||
format?: 'full' | 'short' | 'pinpoint';
|
||||
}
|
||||
|
||||
export interface FormatCitationResult {
|
||||
original: string;
|
||||
formatted: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
export async function formatCitationTool(
|
||||
input: FormatCitationInput,
|
||||
): Promise<FormatCitationResult> {
|
||||
const format = input.format ?? 'full';
|
||||
const trimmed = input.citation.trim();
|
||||
|
||||
// Parse "Section N, <law>" or "Section N <law>"
|
||||
const sectionFirst = trimmed.match(/^Section\s+(\d+[A-Za-z]*(?:\(\d+\))?)[,;]?\s+(.+)$/i);
|
||||
// Parse "<law>, Section N" or "<law> Section N"
|
||||
const sectionLast = trimmed.match(/^(.+?)[,;]?\s*Section\s+(\d+[A-Za-z]*(?:\(\d+\))?)$/i);
|
||||
// Parse "\u00a7N <law>"
|
||||
const paraFirst = trimmed.match(/^\u00a7\s*(\d+[A-Za-z]*(?:\(\d+\))?)[,;]?\s+(.+)$/);
|
||||
|
||||
const section = sectionFirst?.[1] ?? sectionLast?.[2] ?? paraFirst?.[1];
|
||||
const law = sectionFirst?.[2] ?? sectionLast?.[1] ?? paraFirst?.[2] ?? trimmed;
|
||||
|
||||
let formatted: string;
|
||||
switch (format) {
|
||||
case 'short':
|
||||
formatted = section ? `Section ${section}, ${law.split('(')[0].trim()}` : law;
|
||||
break;
|
||||
case 'pinpoint':
|
||||
formatted = section ? `\u00a7${section}` : law;
|
||||
break;
|
||||
case 'full':
|
||||
default:
|
||||
formatted = section ? `Section ${section}, ${law}` : law;
|
||||
break;
|
||||
}
|
||||
|
||||
return { original: input.citation, formatted, format };
|
||||
}
|
||||
81
src/tools/get-eu-basis.ts
Normal file
81
src/tools/get-eu-basis.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* get_eu_basis -- Get EU legal basis for an Israeli statute.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { resolveDocumentId } from '../utils/statute-id.js';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface GetEUBasisInput {
|
||||
document_id: string;
|
||||
include_articles?: boolean;
|
||||
reference_types?: string[];
|
||||
}
|
||||
|
||||
export interface EUBasisResult {
|
||||
eu_document_id: string;
|
||||
eu_document_type: string;
|
||||
eu_document_title: string | null;
|
||||
reference_type: string;
|
||||
reference_count: number;
|
||||
implementation_status: string | null;
|
||||
articles?: string[];
|
||||
}
|
||||
|
||||
export async function getEUBasis(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: GetEUBasisInput,
|
||||
): Promise<ToolResponse<EUBasisResult[]>> {
|
||||
const resolvedId = resolveDocumentId(db, input.document_id);
|
||||
if (!resolvedId) {
|
||||
return { results: [], _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
|
||||
// Check if EU reference tables exist
|
||||
try {
|
||||
db.prepare('SELECT 1 FROM eu_references LIMIT 1').get();
|
||||
} catch {
|
||||
return {
|
||||
results: [],
|
||||
_metadata: {
|
||||
...generateResponseMetadata(db),
|
||||
...{ note: 'EU references not available in this database tier' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
er.eu_document_id,
|
||||
ed.type as eu_document_type,
|
||||
COALESCE(ed.title, ed.short_name) as eu_document_title,
|
||||
er.reference_type,
|
||||
COUNT(*) as reference_count,
|
||||
MAX(er.implementation_status) as implementation_status
|
||||
FROM eu_references er
|
||||
LEFT JOIN eu_documents ed ON ed.id = er.eu_document_id
|
||||
WHERE er.document_id = ?
|
||||
`;
|
||||
const params: string[] = [resolvedId];
|
||||
|
||||
if (input.reference_types && input.reference_types.length > 0) {
|
||||
const placeholders = input.reference_types.map(() => '?').join(', ');
|
||||
sql += ` AND er.reference_type IN (${placeholders})`;
|
||||
params.push(...input.reference_types);
|
||||
}
|
||||
|
||||
sql += ' GROUP BY er.eu_document_id, er.reference_type ORDER BY reference_count DESC';
|
||||
|
||||
const rows = db.prepare(sql).all(...params) as EUBasisResult[];
|
||||
|
||||
if (input.include_articles) {
|
||||
for (const row of rows) {
|
||||
const articles = db.prepare(
|
||||
'SELECT DISTINCT eu_article FROM eu_references WHERE document_id = ? AND eu_document_id = ? AND eu_article IS NOT NULL'
|
||||
).all(resolvedId, row.eu_document_id) as { eu_article: string }[];
|
||||
row.articles = articles.map(a => a.eu_article);
|
||||
}
|
||||
}
|
||||
|
||||
return { results: rows, _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
71
src/tools/get-israeli-implementations.ts
Normal file
71
src/tools/get-israeli-implementations.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* get_israeli_implementations -- Find Israeli statutes implementing a specific EU directive/regulation.
|
||||
*
|
||||
* Note: Israel is not an EU member but has an EU adequacy decision for data protection.
|
||||
* Israeli laws may align with or reference EU directives/regulations, particularly
|
||||
* in data protection (GDPR adequacy) and electronic signatures.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface GetIsraeliImplementationsInput {
|
||||
eu_document_id: string;
|
||||
primary_only?: boolean;
|
||||
in_force_only?: boolean;
|
||||
}
|
||||
|
||||
export interface IsraeliImplementationResult {
|
||||
document_id: string;
|
||||
document_title: string;
|
||||
status: string;
|
||||
reference_type: string;
|
||||
implementation_status: string | null;
|
||||
is_primary: boolean;
|
||||
reference_count: number;
|
||||
}
|
||||
|
||||
export async function getIsraeliImplementations(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: GetIsraeliImplementationsInput,
|
||||
): Promise<ToolResponse<IsraeliImplementationResult[]>> {
|
||||
try {
|
||||
db.prepare('SELECT 1 FROM eu_references LIMIT 1').get();
|
||||
} catch {
|
||||
return {
|
||||
results: [],
|
||||
_metadata: {
|
||||
...generateResponseMetadata(db),
|
||||
...{ note: 'EU references not available in this database tier' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
ld.id as document_id,
|
||||
ld.title as document_title,
|
||||
ld.status,
|
||||
er.reference_type,
|
||||
MAX(er.implementation_status) as implementation_status,
|
||||
MAX(er.is_primary_implementation) as is_primary,
|
||||
COUNT(*) as reference_count
|
||||
FROM eu_references er
|
||||
JOIN legal_documents ld ON ld.id = er.document_id
|
||||
WHERE er.eu_document_id = ?
|
||||
`;
|
||||
const params: (string | number)[] = [input.eu_document_id];
|
||||
|
||||
if (input.primary_only) {
|
||||
sql += ' AND er.is_primary_implementation = 1';
|
||||
}
|
||||
|
||||
if (input.in_force_only) {
|
||||
sql += " AND ld.status = 'in_force'";
|
||||
}
|
||||
|
||||
sql += ' GROUP BY ld.id, er.reference_type ORDER BY is_primary DESC, reference_count DESC';
|
||||
|
||||
const rows = db.prepare(sql).all(...params) as IsraeliImplementationResult[];
|
||||
return { results: rows, _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
71
src/tools/get-provision-eu-basis.ts
Normal file
71
src/tools/get-provision-eu-basis.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* get_provision_eu_basis -- Get EU legal basis for a specific provision.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { resolveDocumentId } from '../utils/statute-id.js';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface GetProvisionEUBasisInput {
|
||||
document_id: string;
|
||||
provision_ref: string;
|
||||
}
|
||||
|
||||
export interface ProvisionEUBasisResult {
|
||||
eu_document_id: string;
|
||||
eu_document_type: string;
|
||||
eu_document_title: string | null;
|
||||
eu_article: string | null;
|
||||
reference_type: string;
|
||||
reference_context: string | null;
|
||||
full_citation: string | null;
|
||||
}
|
||||
|
||||
export async function getProvisionEUBasis(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: GetProvisionEUBasisInput,
|
||||
): Promise<ToolResponse<ProvisionEUBasisResult[]>> {
|
||||
const resolvedId = resolveDocumentId(db, input.document_id);
|
||||
if (!resolvedId) {
|
||||
return { results: [], _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
|
||||
try {
|
||||
db.prepare('SELECT 1 FROM eu_references LIMIT 1').get();
|
||||
} catch {
|
||||
return {
|
||||
results: [],
|
||||
_metadata: {
|
||||
...generateResponseMetadata(db),
|
||||
...{ note: 'EU references not available in this database tier' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Find the provision
|
||||
const ref = input.provision_ref.trim();
|
||||
const provision = db.prepare(
|
||||
"SELECT id FROM legal_provisions WHERE document_id = ? AND (provision_ref = ? OR provision_ref = ? OR section = ?)"
|
||||
).get(resolvedId, ref, `sec${ref}`, ref) as { id: number } | undefined;
|
||||
|
||||
if (!provision) {
|
||||
return { results: [], _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
er.eu_document_id,
|
||||
ed.type as eu_document_type,
|
||||
COALESCE(ed.title, ed.short_name) as eu_document_title,
|
||||
er.eu_article,
|
||||
er.reference_type,
|
||||
er.reference_context,
|
||||
er.full_citation
|
||||
FROM eu_references er
|
||||
LEFT JOIN eu_documents ed ON ed.id = er.eu_document_id
|
||||
WHERE er.provision_id = ?
|
||||
ORDER BY er.reference_type, er.eu_document_id
|
||||
`).all(provision.id) as ProvisionEUBasisResult[];
|
||||
|
||||
return { results: rows, _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
126
src/tools/get-provision.ts
Normal file
126
src/tools/get-provision.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* get_provision -- Retrieve specific provision(s) from an Israeli statute.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { resolveDocumentId } from '../utils/statute-id.js';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface GetProvisionInput {
|
||||
document_id: string;
|
||||
section?: string;
|
||||
provision_ref?: string;
|
||||
as_of_date?: string;
|
||||
}
|
||||
|
||||
export interface ProvisionResult {
|
||||
document_id: string;
|
||||
document_title: string;
|
||||
provision_ref: string;
|
||||
chapter: string | null;
|
||||
section: string;
|
||||
title: string | null;
|
||||
content: string;
|
||||
article_number?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export async function getProvision(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: GetProvisionInput,
|
||||
): Promise<ToolResponse<ProvisionResult[]>> {
|
||||
const resolvedId = resolveDocumentId(db, input.document_id);
|
||||
if (!resolvedId) {
|
||||
return {
|
||||
results: [],
|
||||
_metadata: {
|
||||
...generateResponseMetadata(db),
|
||||
...{ note: `No document found matching "${input.document_id}"` },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const docRow = db.prepare(
|
||||
'SELECT id, title, url FROM legal_documents WHERE id = ?'
|
||||
).get(resolvedId) as { id: string; title: string; url: string | null } | undefined;
|
||||
if (!docRow) {
|
||||
return { results: [], _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
|
||||
// Specific provision lookup
|
||||
const ref = input.provision_ref ?? input.section;
|
||||
if (ref) {
|
||||
const refTrimmed = ref.trim();
|
||||
|
||||
// Try direct provision_ref match
|
||||
let provision = db.prepare(
|
||||
'SELECT * FROM legal_provisions WHERE document_id = ? AND provision_ref = ?'
|
||||
).get(resolvedId, refTrimmed) as Record<string, unknown> | undefined;
|
||||
|
||||
// Try with "sec" prefix (e.g., "1" -> "sec1")
|
||||
if (!provision) {
|
||||
provision = db.prepare(
|
||||
'SELECT * FROM legal_provisions WHERE document_id = ? AND provision_ref = ?'
|
||||
).get(resolvedId, `sec${refTrimmed}`) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
// Try section column match
|
||||
if (!provision) {
|
||||
provision = db.prepare(
|
||||
'SELECT * FROM legal_provisions WHERE document_id = ? AND section = ?'
|
||||
).get(resolvedId, refTrimmed) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
// Try LIKE match for flexible input
|
||||
if (!provision) {
|
||||
provision = db.prepare(
|
||||
"SELECT * FROM legal_provisions WHERE document_id = ? AND (provision_ref LIKE ? OR section LIKE ?)"
|
||||
).get(resolvedId, `%${refTrimmed}%`, `%${refTrimmed}%`) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
if (provision) {
|
||||
return {
|
||||
results: [{
|
||||
document_id: resolvedId,
|
||||
document_title: docRow.title,
|
||||
provision_ref: String(provision.provision_ref),
|
||||
chapter: provision.chapter as string | null,
|
||||
section: String(provision.section),
|
||||
title: provision.title as string | null,
|
||||
content: String(provision.content),
|
||||
article_number: String(provision.provision_ref).replace(/^sec/, ''),
|
||||
url: docRow.url ?? undefined,
|
||||
}],
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
results: [],
|
||||
_metadata: {
|
||||
...generateResponseMetadata(db),
|
||||
...{ note: `Provision "${ref}" not found in document "${resolvedId}"` },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Return all provisions for the document
|
||||
const provisions = db.prepare(
|
||||
'SELECT * FROM legal_provisions WHERE document_id = ? ORDER BY id'
|
||||
).all(resolvedId) as Record<string, unknown>[];
|
||||
|
||||
return {
|
||||
results: provisions.map(p => ({
|
||||
document_id: resolvedId,
|
||||
document_title: docRow.title,
|
||||
provision_ref: String(p.provision_ref),
|
||||
chapter: p.chapter as string | null,
|
||||
section: String(p.section),
|
||||
title: p.title as string | null,
|
||||
content: String(p.content),
|
||||
article_number: String(p.provision_ref).replace(/^sec/, ''),
|
||||
url: docRow.url ?? undefined,
|
||||
})),
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
77
src/tools/list-sources.ts
Normal file
77
src/tools/list-sources.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* list_sources -- Return provenance metadata for all data sources.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { readDbMetadata } from '../capabilities.js';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface SourceInfo {
|
||||
name: string;
|
||||
authority: string;
|
||||
url: string;
|
||||
license: string;
|
||||
coverage: string;
|
||||
languages: string[];
|
||||
}
|
||||
|
||||
export interface ListSourcesResult {
|
||||
sources: SourceInfo[];
|
||||
database: {
|
||||
tier: string;
|
||||
schema_version: string;
|
||||
built_at?: string;
|
||||
document_count: number;
|
||||
provision_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
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 async function listSources(
|
||||
db: InstanceType<typeof Database>,
|
||||
): Promise<ToolResponse<ListSourcesResult>> {
|
||||
const meta = readDbMetadata(db);
|
||||
|
||||
return {
|
||||
results: {
|
||||
sources: [
|
||||
{
|
||||
name: 'Knesset Legislation Database',
|
||||
authority: 'The Knesset (Israeli Parliament)',
|
||||
url: 'https://main.knesset.gov.il/Activity/Legislation',
|
||||
license: 'Government Open Data',
|
||||
coverage:
|
||||
'All Israeli primary legislation (Chukkim), Basic Laws (Chukei Yesod), ' +
|
||||
'and selected regulatory frameworks published in Sefer HaChukkim (Book of Laws)',
|
||||
languages: ['he', 'en'],
|
||||
},
|
||||
{
|
||||
name: 'Israeli Government Legal Information',
|
||||
authority: 'Government of Israel',
|
||||
url: 'https://www.gov.il/en/departments/legalinfo',
|
||||
license: 'Government Publication',
|
||||
coverage:
|
||||
'English translations of major Israeli laws including Basic Laws, Privacy Protection Law, ' +
|
||||
'Companies Law, and key regulatory frameworks',
|
||||
languages: ['en'],
|
||||
},
|
||||
],
|
||||
database: {
|
||||
tier: meta.tier,
|
||||
schema_version: meta.schema_version,
|
||||
built_at: meta.built_at,
|
||||
document_count: safeCount(db, 'SELECT COUNT(*) as count FROM legal_documents'),
|
||||
provision_count: safeCount(db, 'SELECT COUNT(*) as count FROM legal_provisions'),
|
||||
},
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
406
src/tools/registry.ts
Normal file
406
src/tools/registry.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
/**
|
||||
* Tool registry for Israel Law MCP Server.
|
||||
* Shared between stdio (index.ts) and HTTP (api/mcp.ts) entry points.
|
||||
*/
|
||||
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
Tool,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
|
||||
import { searchLegislation, type SearchLegislationInput } from './search-legislation.js';
|
||||
import { getProvision, type GetProvisionInput } from './get-provision.js';
|
||||
import { validateCitationTool, type ValidateCitationInput } from './validate-citation.js';
|
||||
import { buildLegalStance, type BuildLegalStanceInput } from './build-legal-stance.js';
|
||||
import { formatCitationTool, type FormatCitationInput } from './format-citation.js';
|
||||
import { checkCurrency, type CheckCurrencyInput } from './check-currency.js';
|
||||
import { getEUBasis, type GetEUBasisInput } from './get-eu-basis.js';
|
||||
import { getIsraeliImplementations, type GetIsraeliImplementationsInput } from './get-israeli-implementations.js';
|
||||
import { searchEUImplementations, type SearchEUImplementationsInput } from './search-eu-implementations.js';
|
||||
import { getProvisionEUBasis, type GetProvisionEUBasisInput } from './get-provision-eu-basis.js';
|
||||
import { validateEUCompliance, type ValidateEUComplianceInput } from './validate-eu-compliance.js';
|
||||
import { listSources } from './list-sources.js';
|
||||
import { getAbout, type AboutContext } from './about.js';
|
||||
import { detectCapabilities, upgradeMessage } from '../capabilities.js';
|
||||
export type { AboutContext } from './about.js';
|
||||
|
||||
const ABOUT_TOOL: Tool = {
|
||||
name: 'about',
|
||||
description:
|
||||
'Server metadata, dataset statistics, freshness, and provenance. ' +
|
||||
'Call this to verify data coverage, currency, and content basis before relying on results.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
};
|
||||
|
||||
const LIST_SOURCES_TOOL: Tool = {
|
||||
name: 'list_sources',
|
||||
description:
|
||||
'Returns detailed provenance metadata for all data sources used by this server, ' +
|
||||
'including the Knesset Legislation Database and gov.il English translations. ' +
|
||||
'Use this to understand what data is available, its authority, coverage scope, and known limitations. ' +
|
||||
'Also returns dataset statistics (document counts, provision counts) and database build timestamp. ' +
|
||||
'Call this FIRST when you need to understand what Israeli legal data this server covers.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
};
|
||||
|
||||
export const TOOLS: Tool[] = [
|
||||
{
|
||||
name: 'search_legislation',
|
||||
description:
|
||||
'Search Israeli statutes and regulations by keyword using full-text search (FTS5 with BM25 ranking). ' +
|
||||
'Returns matching provisions with document context, snippets with >>> <<< markers around matched terms, and relevance scores. ' +
|
||||
'Supports FTS5 syntax: quoted phrases ("exact match"), boolean operators (AND, OR, NOT), and prefix wildcards (term*). ' +
|
||||
'Results are primarily in English (from official translations) with Hebrew authoritative text where available. ' +
|
||||
'Default limit is 10 results. For broad topics, increase the limit. ' +
|
||||
'Do NOT use this for retrieving a known provision -- use get_provision instead.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Search query in English or Hebrew. Supports FTS5 syntax: ' +
|
||||
'"privacy" for exact term, "data protection" for phrase, term* for prefix.',
|
||||
},
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Optional: filter results to a specific statute by its document ID.',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['in_force', 'amended', 'repealed'],
|
||||
description: 'Optional: filter by legislative status.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum results to return (default: 10, max: 50).',
|
||||
default: 10,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_provision',
|
||||
description:
|
||||
'Retrieve the full text of a specific provision (section) from an Israeli statute. ' +
|
||||
'Specify a document_id (law name, title, or internal ID) and optionally a section or provision_ref. ' +
|
||||
'Omit section/provision_ref to get ALL provisions in the statute (use sparingly -- can be large). ' +
|
||||
'Returns provision text, chapter, section number, and metadata. ' +
|
||||
'Supports law name references (e.g., "Privacy Protection Law 1981"), abbreviations, and full titles. ' +
|
||||
'Use this when you know WHICH provision you want. For discovery, use search_legislation instead.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Statute identifier: law name + year (e.g., "Privacy Protection Law 1981"), ' +
|
||||
'full title (e.g., "Protection of Privacy Regulations (Data Security) 2017"), or internal document ID.',
|
||||
},
|
||||
section: {
|
||||
type: 'string',
|
||||
description: 'Section number (e.g., "1", "7", "2A"). Omit to get all provisions.',
|
||||
},
|
||||
provision_ref: {
|
||||
type: 'string',
|
||||
description: 'Direct provision reference (e.g., "sec1"). Alternative to section parameter.',
|
||||
},
|
||||
},
|
||||
required: ['document_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_citation',
|
||||
description:
|
||||
'Validate an Israeli legal citation against the database -- zero-hallucination check. ' +
|
||||
'Parses the citation, checks that the document and provision exist, and returns warnings about status ' +
|
||||
'(repealed, amended). Use this to verify any citation BEFORE including it in a legal analysis. ' +
|
||||
'Supports formats: "Section N, Privacy Protection Law 1981", "\u00a7N Privacy Protection Law", "\u05e1\u05e2\u05d9\u05e3 N".',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
citation: {
|
||||
type: 'string',
|
||||
description: 'Citation string to validate. Examples: "Section 1, Privacy Protection Law 1981", "\u00a71 Computer Law 1995".',
|
||||
},
|
||||
},
|
||||
required: ['citation'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'build_legal_stance',
|
||||
description:
|
||||
'Build a comprehensive set of citations for a legal question by searching across all Israeli statutes simultaneously. ' +
|
||||
'Returns aggregated results from multiple relevant provisions, useful for legal research on a topic. ' +
|
||||
'Use this for broad legal questions like "What are the data security requirements in Israel?" ' +
|
||||
'rather than looking up a specific known provision.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Legal question or topic to research (e.g., "data security", "privacy protection").',
|
||||
},
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Optional: limit search to one statute by document ID.',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Max results per category (default: 5, max: 20).',
|
||||
default: 5,
|
||||
},
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'format_citation',
|
||||
description:
|
||||
'Format an Israeli legal citation per standard conventions. ' +
|
||||
'Three formats: "full" (formal, e.g., "Section 1, Privacy Protection Law 1981"), ' +
|
||||
'"short" (abbreviated), "pinpoint" (section reference only, e.g., "\u00a71").',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
citation: { type: 'string', description: 'Citation string to format.' },
|
||||
format: {
|
||||
type: 'string',
|
||||
enum: ['full', 'short', 'pinpoint'],
|
||||
description: 'Output format (default: "full").',
|
||||
default: 'full',
|
||||
},
|
||||
},
|
||||
required: ['citation'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'check_currency',
|
||||
description:
|
||||
'Check whether an Israeli statute or provision is currently in force, amended, repealed, or not yet in force. ' +
|
||||
'Returns the document status, issued date, in-force date, and warnings. ' +
|
||||
'Essential before citing any provision -- always verify currency.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Statute identifier (law name, abbreviation, or title).',
|
||||
},
|
||||
provision_ref: {
|
||||
type: 'string',
|
||||
description: 'Optional: provision reference to check a specific section.',
|
||||
},
|
||||
},
|
||||
required: ['document_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_eu_basis',
|
||||
description:
|
||||
'Get the EU legal basis that an Israeli statute aligns with or references. ' +
|
||||
'Israel has an EU adequacy decision for data protection (Privacy Protection Law aligns with GDPR). ' +
|
||||
'Returns EU document identifiers, reference types, and implementation status.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: { type: 'string', description: 'Israeli statute identifier.' },
|
||||
include_articles: {
|
||||
type: 'boolean',
|
||||
description: 'Include specific EU article references (default: false).',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ['document_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_israeli_implementations',
|
||||
description:
|
||||
'Find all Israeli statutes that align with or reference a specific EU directive or regulation. ' +
|
||||
'Given an EU document ID (e.g., "regulation:2016/679" for GDPR), returns matching Israeli statutes. ' +
|
||||
'Note: Israel implements EU law through adequacy recognition and voluntary alignment, not transposition.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
eu_document_id: {
|
||||
type: 'string',
|
||||
description: 'EU document ID (e.g., "regulation:2016/679" for GDPR, "directive:2016/1148" for NIS).',
|
||||
},
|
||||
primary_only: {
|
||||
type: 'boolean',
|
||||
description: 'Return only primary aligning statutes (default: false).',
|
||||
default: false,
|
||||
},
|
||||
in_force_only: {
|
||||
type: 'boolean',
|
||||
description: 'Return only currently in-force statutes (default: false).',
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
required: ['eu_document_id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'search_eu_implementations',
|
||||
description:
|
||||
'Search for EU directives and regulations that have Israeli aligning legislation. ' +
|
||||
'Search by keyword, type (directive/regulation), or year range.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Keyword search across EU document titles.' },
|
||||
type: { type: 'string', enum: ['directive', 'regulation'], description: 'Filter by EU document type.' },
|
||||
year_from: { type: 'number', description: 'Filter by year (from).' },
|
||||
year_to: { type: 'number', description: 'Filter by year (to).' },
|
||||
has_israeli_implementation: {
|
||||
type: 'boolean',
|
||||
description: 'If true, only return EU documents with Israeli aligning legislation.',
|
||||
},
|
||||
limit: { type: 'number', description: 'Max results (default: 20, max: 100).', default: 20 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'get_provision_eu_basis',
|
||||
description:
|
||||
'Get the EU legal basis for a SPECIFIC provision within an Israeli statute. ' +
|
||||
'More granular than get_eu_basis (which operates at the statute level). ' +
|
||||
'Use this for pinpoint EU compliance checks at the provision level.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: { type: 'string', description: 'Israeli statute identifier.' },
|
||||
provision_ref: { type: 'string', description: 'Provision reference (e.g., "sec1" or "1").' },
|
||||
},
|
||||
required: ['document_id', 'provision_ref'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'validate_eu_compliance',
|
||||
description:
|
||||
'Check EU alignment status for an Israeli statute or provision. ' +
|
||||
'Detects references to EU directives, adequacy status, and alignment gaps. ' +
|
||||
'Israel has an EU adequacy decision for data protection. ' +
|
||||
'Returns compliance status (compliant, partial, unclear, not_applicable) with warnings.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: { type: 'string', description: 'Israeli statute identifier.' },
|
||||
provision_ref: { type: 'string', description: 'Optional: check for a specific provision.' },
|
||||
eu_document_id: { type: 'string', description: 'Optional: check against a specific EU document.' },
|
||||
},
|
||||
required: ['document_id'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function buildTools(
|
||||
db?: InstanceType<typeof Database>,
|
||||
context?: AboutContext,
|
||||
): Tool[] {
|
||||
const tools = [...TOOLS, LIST_SOURCES_TOOL];
|
||||
|
||||
if (db) {
|
||||
try {
|
||||
db.prepare('SELECT 1 FROM definitions LIMIT 1').get();
|
||||
// Could add a get_definitions tool here when definitions table exists
|
||||
} catch {
|
||||
// definitions table doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
if (context) {
|
||||
tools.push(ABOUT_TOOL);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
export function registerTools(
|
||||
server: Server,
|
||||
db: InstanceType<typeof Database>,
|
||||
context?: AboutContext,
|
||||
): void {
|
||||
const allTools = buildTools(db, context);
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: allTools };
|
||||
});
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
try {
|
||||
let result: unknown;
|
||||
|
||||
switch (name) {
|
||||
case 'search_legislation':
|
||||
result = await searchLegislation(db, args as unknown as SearchLegislationInput);
|
||||
break;
|
||||
case 'get_provision':
|
||||
result = await getProvision(db, args as unknown as GetProvisionInput);
|
||||
break;
|
||||
case 'validate_citation':
|
||||
result = await validateCitationTool(db, args as unknown as ValidateCitationInput);
|
||||
break;
|
||||
case 'build_legal_stance':
|
||||
result = await buildLegalStance(db, args as unknown as BuildLegalStanceInput);
|
||||
break;
|
||||
case 'format_citation':
|
||||
result = await formatCitationTool(args as unknown as FormatCitationInput);
|
||||
break;
|
||||
case 'check_currency':
|
||||
result = await checkCurrency(db, args as unknown as CheckCurrencyInput);
|
||||
break;
|
||||
case 'get_eu_basis':
|
||||
result = await getEUBasis(db, args as unknown as GetEUBasisInput);
|
||||
break;
|
||||
case 'get_israeli_implementations':
|
||||
result = await getIsraeliImplementations(db, args as unknown as GetIsraeliImplementationsInput);
|
||||
break;
|
||||
case 'search_eu_implementations':
|
||||
result = await searchEUImplementations(db, args as unknown as SearchEUImplementationsInput);
|
||||
break;
|
||||
case 'get_provision_eu_basis':
|
||||
result = await getProvisionEUBasis(db, args as unknown as GetProvisionEUBasisInput);
|
||||
break;
|
||||
case 'validate_eu_compliance':
|
||||
result = await validateEUCompliance(db, args as unknown as ValidateEUComplianceInput);
|
||||
break;
|
||||
case 'list_sources':
|
||||
result = await listSources(db);
|
||||
break;
|
||||
case 'about':
|
||||
if (context) {
|
||||
result = getAbout(db, context);
|
||||
} else {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'About tool not configured.' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: Unknown tool "${name}".` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error: ${message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
91
src/tools/search-eu-implementations.ts
Normal file
91
src/tools/search-eu-implementations.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* search_eu_implementations -- Search EU directives/regulations with Israeli aligning legislation.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface SearchEUImplementationsInput {
|
||||
query?: string;
|
||||
type?: 'directive' | 'regulation';
|
||||
year_from?: number;
|
||||
year_to?: number;
|
||||
has_israeli_implementation?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface EUImplementationSearchResult {
|
||||
eu_document_id: string;
|
||||
type: string;
|
||||
year: number;
|
||||
number: number;
|
||||
title: string | null;
|
||||
short_name: string | null;
|
||||
israeli_statute_count: number;
|
||||
}
|
||||
|
||||
export async function searchEUImplementations(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: SearchEUImplementationsInput,
|
||||
): Promise<ToolResponse<EUImplementationSearchResult[]>> {
|
||||
try {
|
||||
db.prepare('SELECT 1 FROM eu_documents LIMIT 1').get();
|
||||
} catch {
|
||||
return {
|
||||
results: [],
|
||||
_metadata: {
|
||||
...generateResponseMetadata(db),
|
||||
...{ note: 'EU documents not available in this database tier' },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(input.limit ?? 20, 1), 100);
|
||||
|
||||
let sql = `
|
||||
SELECT
|
||||
ed.id as eu_document_id,
|
||||
ed.type,
|
||||
ed.year,
|
||||
ed.number,
|
||||
ed.title,
|
||||
ed.short_name,
|
||||
COUNT(DISTINCT er.document_id) as israeli_statute_count
|
||||
FROM eu_documents ed
|
||||
LEFT JOIN eu_references er ON er.eu_document_id = ed.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: (string | number)[] = [];
|
||||
|
||||
if (input.query) {
|
||||
sql += ' AND (ed.title LIKE ? OR ed.short_name LIKE ? OR ed.description LIKE ?)';
|
||||
params.push(`%${input.query}%`, `%${input.query}%`, `%${input.query}%`);
|
||||
}
|
||||
|
||||
if (input.type) {
|
||||
sql += ' AND ed.type = ?';
|
||||
params.push(input.type);
|
||||
}
|
||||
|
||||
if (input.year_from) {
|
||||
sql += ' AND ed.year >= ?';
|
||||
params.push(input.year_from);
|
||||
}
|
||||
|
||||
if (input.year_to) {
|
||||
sql += ' AND ed.year <= ?';
|
||||
params.push(input.year_to);
|
||||
}
|
||||
|
||||
sql += ' GROUP BY ed.id';
|
||||
|
||||
if (input.has_israeli_implementation) {
|
||||
sql += ' HAVING israeli_statute_count > 0';
|
||||
}
|
||||
|
||||
sql += ' ORDER BY ed.year DESC, ed.number DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
const rows = db.prepare(sql).all(...params) as EUImplementationSearchResult[];
|
||||
return { results: rows, _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
86
src/tools/search-legislation.ts
Normal file
86
src/tools/search-legislation.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* search_legislation -- Full-text search across Israeli statute provisions.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { buildFtsQueryVariants, sanitizeFtsInput } from '../utils/fts-query.js';
|
||||
import { normalizeAsOfDate } from '../utils/as-of-date.js';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface SearchLegislationInput {
|
||||
query: string;
|
||||
document_id?: string;
|
||||
status?: string;
|
||||
as_of_date?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface SearchLegislationResult {
|
||||
document_id: string;
|
||||
document_title: string;
|
||||
provision_ref: string;
|
||||
chapter: string | null;
|
||||
section: string;
|
||||
title: string | null;
|
||||
snippet: string;
|
||||
relevance: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const MAX_LIMIT = 50;
|
||||
|
||||
export async function searchLegislation(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: SearchLegislationInput,
|
||||
): Promise<ToolResponse<SearchLegislationResult[]>> {
|
||||
if (!input.query || input.query.trim().length === 0) {
|
||||
return { results: [], _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
|
||||
const limit = Math.min(Math.max(input.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
|
||||
const queryVariants = buildFtsQueryVariants(sanitizeFtsInput(input.query));
|
||||
|
||||
for (const ftsQuery of queryVariants) {
|
||||
let sql = `
|
||||
SELECT
|
||||
lp.document_id,
|
||||
ld.title as document_title,
|
||||
lp.provision_ref,
|
||||
lp.chapter,
|
||||
lp.section,
|
||||
lp.title,
|
||||
snippet(provisions_fts, 0, '>>>', '<<<', '...', 32) as snippet,
|
||||
bm25(provisions_fts) as relevance
|
||||
FROM provisions_fts
|
||||
JOIN legal_provisions lp ON lp.id = provisions_fts.rowid
|
||||
JOIN legal_documents ld ON ld.id = lp.document_id
|
||||
WHERE provisions_fts MATCH ?
|
||||
`;
|
||||
const params: (string | number)[] = [ftsQuery];
|
||||
|
||||
if (input.document_id) {
|
||||
sql += ' AND lp.document_id = ?';
|
||||
params.push(input.document_id);
|
||||
}
|
||||
|
||||
if (input.status) {
|
||||
sql += ' AND ld.status = ?';
|
||||
params.push(input.status);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY relevance LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
try {
|
||||
const rows = db.prepare(sql).all(...params) as SearchLegislationResult[];
|
||||
if (rows.length > 0) {
|
||||
return { results: rows, _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
} catch {
|
||||
// FTS query syntax error -- try next variant
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return { results: [], _metadata: generateResponseMetadata(db) };
|
||||
}
|
||||
161
src/tools/validate-citation.ts
Normal file
161
src/tools/validate-citation.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* validate_citation -- Validate an Israeli legal citation against the database.
|
||||
*
|
||||
* Supports citation formats:
|
||||
* - "Section N, [Law Name Year]"
|
||||
* - "Section N [Law Name]"
|
||||
* - "\u00a7N [Law Name]"
|
||||
* - "\u05e1\u05e2\u05d9\u05e3 N" (Hebrew: se'if N)
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { resolveDocumentId } from '../utils/statute-id.js';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface ValidateCitationInput {
|
||||
citation: string;
|
||||
}
|
||||
|
||||
export interface ValidateCitationResult {
|
||||
valid: boolean;
|
||||
citation: string;
|
||||
normalized?: string;
|
||||
document_id?: string;
|
||||
document_title?: string;
|
||||
provision_ref?: string;
|
||||
status?: string;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an Israeli legal citation.
|
||||
* Supports:
|
||||
* - "Section N, Law Name Year" / "Section N Law Name"
|
||||
* - "\u00a7N Law Name" / "\u00a7 N Law Name"
|
||||
* - "\u05e1\u05e2\u05d9\u05e3 N, Law Name" (Hebrew)
|
||||
* - "Law Name, Section N"
|
||||
* - Just a law name
|
||||
*/
|
||||
function parseCitation(citation: string): { documentRef: string; sectionRef?: string } | null {
|
||||
const trimmed = citation.trim();
|
||||
|
||||
// "Section N, <law>" or "Section N <law>"
|
||||
const sectionFirst = trimmed.match(/^Section\s+(\d+[A-Za-z]*(?:\(\d+\))?)[,;]?\s+(.+)$/i);
|
||||
if (sectionFirst) {
|
||||
return { documentRef: sectionFirst[2].trim(), sectionRef: sectionFirst[1] };
|
||||
}
|
||||
|
||||
// "\u00a7N <law>" or "\u00a7 N <law>"
|
||||
const paraFirst = trimmed.match(/^\u00a7\s*(\d+[A-Za-z]*(?:\(\d+\))?)[,;]?\s+(.+)$/);
|
||||
if (paraFirst) {
|
||||
return { documentRef: paraFirst[2].trim(), sectionRef: paraFirst[1] };
|
||||
}
|
||||
|
||||
// "\u05e1\u05e2\u05d9\u05e3 N, <law>" (Hebrew: se'if)
|
||||
const hebrewSection = trimmed.match(/^\u05e1\u05e2\u05d9\u05e3\s+(\d+[A-Za-z]*(?:\(\d+\))?)[,;]?\s+(.+)$/);
|
||||
if (hebrewSection) {
|
||||
return { documentRef: hebrewSection[2].trim(), sectionRef: hebrewSection[1] };
|
||||
}
|
||||
|
||||
// "<law>, Section N" or "<law> Section N"
|
||||
const sectionLast = trimmed.match(/^(.+?)[,;]?\s*Section\s+(\d+[A-Za-z]*(?:\(\d+\))?)$/i);
|
||||
if (sectionLast) {
|
||||
return { documentRef: sectionLast[1].trim(), sectionRef: sectionLast[2] };
|
||||
}
|
||||
|
||||
// "<law>, \u00a7N" or "<law> \u00a7N"
|
||||
const paraLast = trimmed.match(/^(.+?)[,;]?\s*\u00a7\s*(\d+[A-Za-z]*(?:\(\d+\))?)$/);
|
||||
if (paraLast) {
|
||||
return { documentRef: paraLast[1].trim(), sectionRef: paraLast[2] };
|
||||
}
|
||||
|
||||
// Just a document reference
|
||||
return { documentRef: trimmed };
|
||||
}
|
||||
|
||||
export async function validateCitationTool(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: ValidateCitationInput,
|
||||
): Promise<ToolResponse<ValidateCitationResult>> {
|
||||
const warnings: string[] = [];
|
||||
const parsed = parseCitation(input.citation);
|
||||
|
||||
if (!parsed) {
|
||||
return {
|
||||
results: {
|
||||
valid: false,
|
||||
citation: input.citation,
|
||||
warnings: ['Could not parse citation format'],
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
|
||||
const docId = resolveDocumentId(db, parsed.documentRef);
|
||||
if (!docId) {
|
||||
return {
|
||||
results: {
|
||||
valid: false,
|
||||
citation: input.citation,
|
||||
warnings: [`Document not found: "${parsed.documentRef}"`],
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
|
||||
const doc = db.prepare(
|
||||
'SELECT id, title, status FROM legal_documents WHERE id = ?'
|
||||
).get(docId) as { id: string; title: string; status: string };
|
||||
|
||||
if (doc.status === 'repealed') {
|
||||
warnings.push(`WARNING: This statute has been repealed.`);
|
||||
} else if (doc.status === 'amended') {
|
||||
warnings.push(`Note: This statute has been amended. Verify you are referencing the current version.`);
|
||||
}
|
||||
|
||||
if (parsed.sectionRef) {
|
||||
const provision = db.prepare(
|
||||
"SELECT provision_ref FROM legal_provisions WHERE document_id = ? AND (provision_ref = ? OR provision_ref = ? OR section = ?)"
|
||||
).get(docId, parsed.sectionRef, `sec${parsed.sectionRef}`, parsed.sectionRef) as { provision_ref: string } | undefined;
|
||||
|
||||
if (!provision) {
|
||||
return {
|
||||
results: {
|
||||
valid: false,
|
||||
citation: input.citation,
|
||||
document_id: docId,
|
||||
document_title: doc.title,
|
||||
warnings: [...warnings, `Provision "${parsed.sectionRef}" not found in ${doc.title}`],
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
results: {
|
||||
valid: true,
|
||||
citation: input.citation,
|
||||
normalized: `Section ${parsed.sectionRef}, ${doc.title}`,
|
||||
document_id: docId,
|
||||
document_title: doc.title,
|
||||
provision_ref: provision.provision_ref,
|
||||
status: doc.status,
|
||||
warnings,
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
results: {
|
||||
valid: true,
|
||||
citation: input.citation,
|
||||
normalized: doc.title,
|
||||
document_id: docId,
|
||||
document_title: doc.title,
|
||||
status: doc.status,
|
||||
warnings,
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
134
src/tools/validate-eu-compliance.ts
Normal file
134
src/tools/validate-eu-compliance.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* validate_eu_compliance -- Check EU alignment status for an Israeli statute.
|
||||
*
|
||||
* Israel has an EU adequacy decision for data protection, meaning the European
|
||||
* Commission recognizes Israel's data protection framework as providing adequate
|
||||
* safeguards. This tool checks alignment status for Israeli statutes.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
import { resolveDocumentId } from '../utils/statute-id.js';
|
||||
import { generateResponseMetadata, type ToolResponse } from '../utils/metadata.js';
|
||||
|
||||
export interface ValidateEUComplianceInput {
|
||||
document_id: string;
|
||||
provision_ref?: string;
|
||||
eu_document_id?: string;
|
||||
}
|
||||
|
||||
export interface EUComplianceResult {
|
||||
document_id: string;
|
||||
document_title: string;
|
||||
compliance_status: 'compliant' | 'partial' | 'unclear' | 'not_applicable';
|
||||
eu_references_found: number;
|
||||
warnings: string[];
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export async function validateEUCompliance(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: ValidateEUComplianceInput,
|
||||
): Promise<ToolResponse<EUComplianceResult>> {
|
||||
const resolvedId = resolveDocumentId(db, input.document_id);
|
||||
if (!resolvedId) {
|
||||
return {
|
||||
results: {
|
||||
document_id: input.document_id,
|
||||
document_title: 'Unknown',
|
||||
compliance_status: 'not_applicable',
|
||||
eu_references_found: 0,
|
||||
warnings: [`Document not found: "${input.document_id}"`],
|
||||
recommendations: [],
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
|
||||
const doc = db.prepare(
|
||||
'SELECT id, title, status FROM legal_documents WHERE id = ?'
|
||||
).get(resolvedId) as { id: string; title: string; status: string };
|
||||
|
||||
const warnings: string[] = [];
|
||||
const recommendations: string[] = [];
|
||||
|
||||
// Check if EU reference tables exist
|
||||
let euRefCount = 0;
|
||||
try {
|
||||
let sql = 'SELECT COUNT(*) as count FROM eu_references WHERE document_id = ?';
|
||||
const params: string[] = [resolvedId];
|
||||
|
||||
if (input.eu_document_id) {
|
||||
sql += ' AND eu_document_id = ?';
|
||||
params.push(input.eu_document_id);
|
||||
}
|
||||
|
||||
const row = db.prepare(sql).get(...params) as { count: number };
|
||||
euRefCount = row.count;
|
||||
} catch {
|
||||
return {
|
||||
results: {
|
||||
document_id: resolvedId,
|
||||
document_title: doc.title,
|
||||
compliance_status: 'not_applicable',
|
||||
eu_references_found: 0,
|
||||
warnings: ['EU references not available in this database tier'],
|
||||
recommendations: [],
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
|
||||
if (euRefCount === 0) {
|
||||
return {
|
||||
results: {
|
||||
document_id: resolvedId,
|
||||
document_title: doc.title,
|
||||
compliance_status: 'not_applicable',
|
||||
eu_references_found: 0,
|
||||
warnings: [],
|
||||
recommendations: ['No EU cross-references found for this statute. This may be a purely domestic law.'],
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
|
||||
if (doc.status === 'repealed') {
|
||||
warnings.push('This statute has been repealed.');
|
||||
recommendations.push('Check for replacement legislation.');
|
||||
}
|
||||
|
||||
// Check implementation status
|
||||
const statuses = db.prepare(
|
||||
'SELECT implementation_status, COUNT(*) as count FROM eu_references WHERE document_id = ? GROUP BY implementation_status'
|
||||
).all(resolvedId) as { implementation_status: string | null; count: number }[];
|
||||
|
||||
const statusMap = new Map(statuses.map(s => [s.implementation_status, s.count]));
|
||||
const completeCount = statusMap.get('complete') ?? 0;
|
||||
const partialCount = statusMap.get('partial') ?? 0;
|
||||
const unknownCount = statusMap.get('unknown') ?? 0;
|
||||
|
||||
let compliance_status: 'compliant' | 'partial' | 'unclear' | 'not_applicable';
|
||||
if (completeCount > 0 && partialCount === 0 && unknownCount === 0) {
|
||||
compliance_status = 'compliant';
|
||||
} else if (partialCount > 0) {
|
||||
compliance_status = 'partial';
|
||||
warnings.push(`${partialCount} EU reference(s) have partial implementation status.`);
|
||||
} else {
|
||||
compliance_status = 'unclear';
|
||||
if (unknownCount > 0) {
|
||||
recommendations.push(`${unknownCount} EU reference(s) have unknown implementation status. Manual review recommended.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results: {
|
||||
document_id: resolvedId,
|
||||
document_title: doc.title,
|
||||
compliance_status,
|
||||
eu_references_found: euRefCount,
|
||||
warnings,
|
||||
recommendations,
|
||||
},
|
||||
_metadata: generateResponseMetadata(db),
|
||||
};
|
||||
}
|
||||
27
src/utils/as-of-date.ts
Normal file
27
src/utils/as-of-date.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Date normalization for temporal queries.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize an as-of date string to ISO 8601 format.
|
||||
* Returns null if the input is not a valid date.
|
||||
*/
|
||||
export function normalizeAsOfDate(input?: string): string | null {
|
||||
if (!input || input.trim().length === 0) return null;
|
||||
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Already ISO 8601
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) {
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) return trimmed;
|
||||
}
|
||||
|
||||
// Try parsing as a general date
|
||||
const date = new Date(trimmed);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
52
src/utils/fts-query.ts
Normal file
52
src/utils/fts-query.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* FTS5 query helpers for Israel Law MCP.
|
||||
*
|
||||
* Handles query sanitization and variant generation for SQLite FTS5.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sanitize user input for safe FTS5 queries.
|
||||
* Removes characters that have special meaning in FTS5 syntax.
|
||||
*/
|
||||
export function sanitizeFtsInput(input: string): string {
|
||||
return input
|
||||
.replace(/['"(){}[\]^~*:]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build FTS5 query variants for a search term.
|
||||
* Returns variants in order of specificity (most specific first):
|
||||
* 1. Exact phrase match
|
||||
* 2. All terms required (AND)
|
||||
* 3. Prefix match on last term
|
||||
*/
|
||||
export function buildFtsQueryVariants(sanitized: string): string[] {
|
||||
if (!sanitized || sanitized.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const terms = sanitized.split(/\s+/).filter(t => t.length > 0);
|
||||
if (terms.length === 0) return [];
|
||||
|
||||
const variants: string[] = [];
|
||||
|
||||
// Exact phrase
|
||||
if (terms.length > 1) {
|
||||
variants.push(`"${terms.join(' ')}"`);
|
||||
}
|
||||
|
||||
// AND query
|
||||
variants.push(terms.join(' AND '));
|
||||
|
||||
// Prefix match on last term (for autocomplete-like behavior)
|
||||
if (terms.length === 1 && terms[0].length >= 3) {
|
||||
variants.push(`${terms[0]}*`);
|
||||
} else if (terms.length > 1) {
|
||||
const prefix = [...terms.slice(0, -1), `${terms[terms.length - 1]}*`];
|
||||
variants.push(prefix.join(' AND '));
|
||||
}
|
||||
|
||||
return variants;
|
||||
}
|
||||
41
src/utils/metadata.ts
Normal file
41
src/utils/metadata.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Response metadata utilities for Israel Law MCP.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
|
||||
export interface ResponseMetadata {
|
||||
data_source: string;
|
||||
jurisdiction: string;
|
||||
disclaimer: string;
|
||||
freshness?: string;
|
||||
}
|
||||
|
||||
export interface ToolResponse<T> {
|
||||
results: T;
|
||||
_metadata: ResponseMetadata;
|
||||
}
|
||||
|
||||
export function generateResponseMetadata(
|
||||
db: InstanceType<typeof Database>,
|
||||
): ResponseMetadata {
|
||||
let freshness: string | undefined;
|
||||
try {
|
||||
const row = db.prepare(
|
||||
"SELECT value FROM db_metadata WHERE key = 'built_at'"
|
||||
).get() as { value: string } | undefined;
|
||||
if (row) freshness = row.value;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return {
|
||||
data_source: 'Knesset Legislation Database (knesset.gov.il) + gov.il English translations',
|
||||
jurisdiction: 'IL',
|
||||
disclaimer:
|
||||
'This data is sourced from the Knesset Legislation Database and Israeli government publications. ' +
|
||||
'Hebrew is the legally authoritative language. English translations are unofficial. ' +
|
||||
'Always verify with the official Knesset or Nevo portals.',
|
||||
freshness,
|
||||
};
|
||||
}
|
||||
49
src/utils/statute-id.ts
Normal file
49
src/utils/statute-id.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Statute ID resolution for Israel Law MCP.
|
||||
*
|
||||
* Resolves fuzzy document references (titles, law names) to database document IDs.
|
||||
*/
|
||||
|
||||
import type Database from '@ansvar/mcp-sqlite';
|
||||
|
||||
/**
|
||||
* Resolve a document identifier to a database document ID.
|
||||
* Supports:
|
||||
* - Direct ID match (e.g., "privacy-protection-law-1981")
|
||||
* - Law name + year match (e.g., "Privacy Protection Law 1981")
|
||||
* - Title substring match (e.g., "Privacy Protection", "Computer Law")
|
||||
* - Short name match (e.g., "PPL", "Computer Law")
|
||||
*/
|
||||
export function resolveDocumentId(
|
||||
db: InstanceType<typeof Database>,
|
||||
input: string,
|
||||
): string | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Direct ID match
|
||||
const directMatch = db.prepare(
|
||||
'SELECT id FROM legal_documents WHERE id = ?'
|
||||
).get(trimmed) as { id: string } | undefined;
|
||||
if (directMatch) return directMatch.id;
|
||||
|
||||
// Short name exact match (case-insensitive)
|
||||
const shortNameMatch = db.prepare(
|
||||
"SELECT id FROM legal_documents WHERE LOWER(short_name) = LOWER(?) LIMIT 1"
|
||||
).get(trimmed) as { id: string } | undefined;
|
||||
if (shortNameMatch) return shortNameMatch.id;
|
||||
|
||||
// Title/short_name fuzzy match
|
||||
const titleResult = db.prepare(
|
||||
"SELECT id FROM legal_documents WHERE title LIKE ? OR short_name LIKE ? OR title_en LIKE ? LIMIT 1"
|
||||
).get(`%${trimmed}%`, `%${trimmed}%`, `%${trimmed}%`) as { id: string } | undefined;
|
||||
if (titleResult) return titleResult.id;
|
||||
|
||||
// Case-insensitive fallback
|
||||
const lowerResult = db.prepare(
|
||||
"SELECT id FROM legal_documents WHERE LOWER(title) LIKE LOWER(?) OR LOWER(short_name) LIKE LOWER(?) OR LOWER(title_en) LIKE LOWER(?) LIMIT 1"
|
||||
).get(`%${trimmed}%`, `%${trimmed}%`, `%${trimmed}%`) as { id: string } | undefined;
|
||||
if (lowerResult) return lowerResult.id;
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user