// src/tna-client.ts // ============================================================================ // THE NATIONAL ARCHIVES API CLIENT // ============================================================================ // // Handles all communication with the TNA Find Case Law API. // // Rate limits: 1,000 requests per 5-minute rolling window // No authentication required. // ============================================================================ import { XMLParser } from 'fast-xml-parser'; import type { CaseMetadata, CaseParagraph, SearchResult, DocumentUrls } from './types.js'; const TNA_BASE_URL = 'https://caselaw.nationalarchives.gov.uk'; // Generate URLs for a document function generateDocumentUrls(documentUri: string): DocumentUrls { const normalizedUri = documentUri.startsWith('/') ? documentUri.substring(1) : documentUri; return { web: `${TNA_BASE_URL}/${normalizedUri}`, pdf: `${TNA_BASE_URL}/${normalizedUri}/data.pdf`, xml: `${TNA_BASE_URL}/${normalizedUri}/data.xml`, }; } // Rate limiting: simple token bucket export const RATE_LIMIT_MAX_REQUESTS = 900; // Stay under 1000 limit export const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes let requestCount = 0; let windowStart = Date.now(); async function checkRateLimit(): Promise { const now = Date.now(); if (now - windowStart > RATE_LIMIT_WINDOW_MS) { // Reset window requestCount = 0; windowStart = now; } if (requestCount >= RATE_LIMIT_MAX_REQUESTS) { const waitTime = RATE_LIMIT_WINDOW_MS - (now - windowStart); console.error(`Rate limit reached, waiting ${waitTime}ms`); await new Promise(resolve => setTimeout(resolve, waitTime)); requestCount = 0; windowStart = Date.now(); } requestCount++; } // ============================================================================ // COURT CODE MAPPING // ============================================================================ // Map user-friendly court names to TNA API codes export const COURT_CODE_MAP: Record = { supreme_court: ['uksc'], court_of_appeal: ['ewca/civ', 'ewca/crim'], high_court: [ 'ewhc/ch', 'ewhc/qb', 'ewhc/kb', 'ewhc/admin', 'ewhc/comm', 'ewhc/patents', 'ewhc/ipec', 'ewhc/tcc', 'ewhc/fam' ], tribunals: ['eat', 'ukut/iac', 'ukut/lc', 'ukut/aac'], }; // Map legal areas to relevant courts export const LEGAL_AREA_COURTS: Record = { intellectual_property: ['ewhc/patents', 'ewhc/ipec', 'ewhc/ch'], commercial: ['ewhc/comm', 'ewhc/ch'], employment: ['eat'], immigration: ['ukut/iac'], family: ['ewhc/fam'], }; // ============================================================================ // SEARCH FUNCTION // ============================================================================ export interface TnaSearchParams { query: string; courts?: string[]; yearFrom?: number; yearTo?: number; limit?: number; page?: number; } export async function searchTna(params: TnaSearchParams): Promise { await checkRateLimit(); const url = new URL(`${TNA_BASE_URL}/atom.xml`); url.searchParams.set('query', params.query); url.searchParams.set('order', '-date'); // Newest first url.searchParams.set('per_page', String(Math.min(params.limit || 50, 50))); if (params.page) { url.searchParams.set('page', String(params.page)); } // Note: Court filtering via API is currently disabled due to API format issues // The TNA API returns 400 for court parameter. Filter results locally instead. // TODO: Investigate correct court filter format when TNA API docs are updated // Note: TNA API doesn't have native year filtering in search // We filter results after fetching const response = await fetch(url.toString(), { headers: { 'Accept': 'application/atom+xml', 'User-Agent': 'UKCaseLawMCP/1.0', }, }); if (!response.ok) { if (response.status === 429) { throw new Error('TNA API rate limit exceeded. Please wait a few minutes.'); } throw new Error(`TNA API error: ${response.status} ${response.statusText}`); } const xml = await response.text(); const results = parseAtomFeed(xml); // Apply year filtering return results.filter(r => { if (!r.date) return true; const year = new Date(r.date).getFullYear(); if (params.yearFrom && year < params.yearFrom) return false; if (params.yearTo && year > params.yearTo) return false; return true; }); } // ============================================================================ // ATOM FEED PARSER // ============================================================================ interface AtomLink { '@_href': string; '@_type': string; } interface AtomEntry { id: string; title: string | { '#text': string }; published?: string; updated?: string; summary?: string | { '#text': string }; author?: { name: string }; link?: AtomLink | AtomLink[]; 'tna:identifier'?: Array<{ '@_value': string; '@_type': string; '@_slug'?: string }> | { '@_value': string; '@_type': string; '@_slug'?: string }; 'tna:uri'?: string; } interface AtomFeedResult { feed: { entry?: AtomEntry | AtomEntry[]; }; } function parseAtomFeed(xml: string): SearchResult[] { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', }); const feed = parser.parse(xml) as AtomFeedResult; // Handle empty results if (!feed.feed || !feed.feed.entry) { return []; } // Normalize to array (single result comes as object) const entries = Array.isArray(feed.feed.entry) ? feed.feed.entry : [feed.feed.entry]; return entries.map((entry) => { // Extract neutral citation and document slug from identifier elements let neutralCitation: string | null = null; let documentSlug: string | null = null; if (entry['tna:identifier']) { const identifiers = Array.isArray(entry['tna:identifier']) ? entry['tna:identifier'] : [entry['tna:identifier']]; const ncn = identifiers.find((i) => i['@_type'] === 'ukncn'); if (ncn) { neutralCitation = ncn['@_value']; // The slug is in the same identifier element documentSlug = ncn['@_slug'] || null; } } // Fall back to extracting URI from the alternate link if slug not found if (!documentSlug && entry.link) { const links = Array.isArray(entry.link) ? entry.link : [entry.link]; const xmlLink = links.find((l) => l['@_type'] === 'application/akn+xml'); if (xmlLink?.['@_href']) { // Extract slug from URL like https://caselaw.../ewca/civ/2025/1633/data.xml const match = xmlLink['@_href'].match(/nationalarchives\.gov\.uk\/(.+)\/data\.xml$/); if (match?.[1]) { documentSlug = match[1]; } } } // Extract snippet - handle cases where summary is an object or empty let snippet = ''; if (entry.summary) { if (typeof entry.summary === 'string') { snippet = entry.summary; } else if (entry.summary['#text']) { snippet = entry.summary['#text']; } } const docUri = documentSlug || entry['tna:uri'] || extractUriFromId(entry.id); // Handle title being string or object let title = 'Untitled'; if (typeof entry.title === 'string') { title = entry.title; } else if (entry.title && typeof entry.title === 'object' && entry.title['#text']) { title = entry.title['#text']; } return { documentUri: docUri, neutralCitation, title, court: entry.author?.name || 'Unknown Court', date: entry.published ? entry.published.substring(0, 10) : null, snippet, source: 'tna' as const, score: 1.0, // TNA doesn't provide relevance scores urls: generateDocumentUrls(docUri), }; }); } function extractUriFromId(id: string): string { // ID format: https://caselaw.nationalarchives.gov.uk/ewca/civ/2007/588 const match = id.match(/nationalarchives\.gov\.uk\/(.+)$/); return match?.[1] ?? id; } // ============================================================================ // GET CASE CONTENT // ============================================================================ export async function getTnaCaseContent(uri: string): Promise<{ metadata: CaseMetadata; paragraphs: CaseParagraph[]; judges: string[]; } | null> { await checkRateLimit(); // Normalize URI (remove leading slash if present) const normalizedUri = uri.startsWith('/') ? uri.substring(1) : uri; const url = `${TNA_BASE_URL}/${normalizedUri}/data.xml`; const response = await fetch(url, { headers: { 'Accept': 'application/xml', 'User-Agent': 'UKCaseLawMCP/1.0', }, }); if (!response.ok) { if (response.status === 404) { return null; } throw new Error(`TNA API error: ${response.status} ${response.statusText}`); } const xml = await response.text(); return parseLegalDocML(xml, normalizedUri); } // ============================================================================ // LEGALDOCML PARSER // ============================================================================ // // TNA uses Akoma Ntoso (LegalDocML) XML format. // Key namespaces: // - akn: http://docs.oasis-open.org/legaldocml/ns/akn/3.0 // - uk: https://caselaw.nationalarchives.gov.uk/akn // ============================================================================ function parseLegalDocML(xml: string, uri: string): { metadata: CaseMetadata; paragraphs: CaseParagraph[]; judges: string[]; } { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', removeNSPrefix: true, // Remove namespace prefixes for easier access }); const doc = parser.parse(xml); // Navigate to the judgment content const akomaNtoso = doc.akomaNtoso || doc['akn:akomaNtoso'] || {}; const judgment = akomaNtoso.judgment || {}; const meta = judgment.meta || {}; const judgmentBody = judgment.judgmentBody || {}; // Extract metadata const identification = meta.identification || {}; const frbrWork = identification.FRBRWork || {}; // Get title let title = 'Untitled'; if (frbrWork.FRBRname && frbrWork.FRBRname['@_value']) { title = frbrWork.FRBRname['@_value']; } // Get date let date: string | null = null; if (frbrWork.FRBRdate && frbrWork.FRBRdate['@_date']) { date = frbrWork.FRBRdate['@_date']; } // Get neutral citation let neutralCitation: string | null = null; const proprietary = meta.proprietary || {}; if (proprietary.cite) { neutralCitation = proprietary.cite; } // Get court let court = 'Unknown'; let courtName = 'Unknown Court'; if (proprietary.court) { court = proprietary.court; } if (proprietary.courtName) { courtName = proprietary.courtName; } // Extract judges from header const judges: string[] = []; const header = judgment.header || {}; if (header.judge) { const judgeElements = Array.isArray(header.judge) ? header.judge : [header.judge]; for (const j of judgeElements) { if (typeof j === 'string') { judges.push(j); } else if (j['#text']) { judges.push(j['#text']); } } } // Extract paragraphs from judgment body const paragraphs: CaseParagraph[] = []; extractParagraphs(judgmentBody, paragraphs); return { metadata: { documentUri: uri, neutralCitation, title, court, courtName, date, source: 'tna', urls: generateDocumentUrls(uri), }, paragraphs, judges, }; } function extractParagraphs(node: any, paragraphs: CaseParagraph[], depth = 0): void { if (!node || typeof node !== 'object') return; // Look for paragraph elements if (node.paragraph || node.p) { const paraElements = node.paragraph || node.p; const paras = Array.isArray(paraElements) ? paraElements : [paraElements]; for (const para of paras) { // Get paragraph number - handle number, string, or object with #text let paraNum = paragraphs.length + 1; if (para.num !== undefined && para.num !== null) { if (typeof para.num === 'number') { paraNum = para.num; } else if (typeof para.num === 'string') { const match = para.num.match(/\d+/); if (match?.[0]) { paraNum = parseInt(match[0], 10); } } else if (typeof para.num === 'object' && para.num['#text']) { const numText = String(para.num['#text']); const match = numText.match(/\d+/); if (match?.[0]) { paraNum = parseInt(match[0], 10); } } } // Get paragraph text let text = ''; if (para.content) { text = extractText(para.content); } else if (para['#text']) { text = para['#text']; } else { text = extractText(para); } if (text.trim()) { paragraphs.push({ number: paraNum, text: text.trim(), }); } } } // Recurse into child nodes for (const key of Object.keys(node)) { if (key.startsWith('@_') || key === '#text') continue; extractParagraphs(node[key], paragraphs, depth + 1); } } function extractText(node: any): string { if (typeof node === 'string') return node; if (typeof node !== 'object' || node === null) return ''; let text = ''; if (node['#text']) { text += node['#text']; } for (const key of Object.keys(node)) { if (key.startsWith('@_') || key === '#text') continue; const child = node[key]; if (Array.isArray(child)) { for (const item of child) { text += ' ' + extractText(item); } } else { text += ' ' + extractText(child); } } return text.replace(/\s+/g, ' ').trim(); } // ============================================================================ // CITATION PARSING // ============================================================================ // Convert neutral citation to TNA URI export function citationToUri(citation: string): string | null { // Pattern: [YEAR] COURT NUMBER // Examples: // [2024] UKSC 1 -> uksc/2024/1 // [2007] EWCA Civ 588 -> ewca/civ/2007/588 // [2023] EWHC 123 (Patents) -> ewhc/patents/2023/123 const match = citation.match(/\[(\d{4})\]\s+(\w+)\s+(?:(\w+)\s+)?(\d+)/); if (!match) return null; const year = match[1]; const court1 = match[2]; const court2 = match[3]; const number = match[4]; if (!year || !court1 || !number) return null; const courtParts = [court1.toLowerCase()]; if (court2) { courtParts.push(court2.toLowerCase()); } // Handle subdivision in parentheses like (Patents) const subdivMatch = citation.match(/\((\w+)\)/); if (subdivMatch?.[1]) { courtParts.push(subdivMatch[1].toLowerCase()); } return `${courtParts.join('/')}/${year}/${number}`; }