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:
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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user