@iflow-mcp/georgejeffers-uk-case-law-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ // src/formatters.ts
2
+ // ============================================================================
3
+ // OUTPUT FORMATTERS
4
+ // ============================================================================
5
+ //
6
+ // Format search results and case content for Claude.
7
+ // Designed to be readable and include proper citations.
8
+ // ============================================================================
9
+
10
+ import type { SearchResult, CaseContent, CaseParagraph } from './types.js';
11
+
12
+ // ============================================================================
13
+ // PARAGRAPH RANGE UTILITIES
14
+ // ============================================================================
15
+
16
+ export function parseParaRange(range: string): { start: number; end: number } | null {
17
+ const match = range.match(/^(\d+)\s*-\s*(\d+)$/);
18
+ if (!match || !match[1] || !match[2]) return null;
19
+ return {
20
+ start: parseInt(match[1], 10),
21
+ end: parseInt(match[2], 10),
22
+ };
23
+ }
24
+
25
+ // Maximum characters to return (roughly 8000 tokens)
26
+ const MAX_OUTPUT_CHARS = 32000;
27
+
28
+ export function applyParaRange(
29
+ paragraphs: CaseParagraph[],
30
+ range: string | undefined
31
+ ): { paragraphs: CaseParagraph[]; truncated: boolean; remaining: number } {
32
+ if (!range) {
33
+ // No range specified - apply token limit
34
+ return truncateToLimit(paragraphs);
35
+ }
36
+
37
+ const parsed = parseParaRange(range);
38
+ if (!parsed) {
39
+ return truncateToLimit(paragraphs);
40
+ }
41
+
42
+ const filtered = paragraphs.filter(
43
+ p => p.number >= parsed.start && p.number <= parsed.end
44
+ );
45
+
46
+ return truncateToLimit(filtered);
47
+ }
48
+
49
+ function truncateToLimit(paragraphs: CaseParagraph[]): {
50
+ paragraphs: CaseParagraph[];
51
+ truncated: boolean;
52
+ remaining: number;
53
+ } {
54
+ let totalChars = 0;
55
+ const output: CaseParagraph[] = [];
56
+
57
+ for (const para of paragraphs) {
58
+ if (totalChars + para.text.length > MAX_OUTPUT_CHARS) {
59
+ break;
60
+ }
61
+ output.push(para);
62
+ totalChars += para.text.length;
63
+ }
64
+
65
+ return {
66
+ paragraphs: output,
67
+ truncated: output.length < paragraphs.length,
68
+ remaining: paragraphs.length - output.length,
69
+ };
70
+ }
71
+
72
+ // ============================================================================
73
+ // SEARCH RESULTS FORMATTER
74
+ // ============================================================================
75
+
76
+ export function formatSearchResults(results: SearchResult[]): string {
77
+ if (results.length === 0) {
78
+ return 'No cases found matching your search query.';
79
+ }
80
+
81
+ let output = `Found ${results.length} case${results.length === 1 ? '' : 's'}:\n\n`;
82
+
83
+ for (const result of results) {
84
+ // Citation or URI as header
85
+ const citation = result.neutralCitation || result.documentUri;
86
+ output += `**${citation}**\n`;
87
+
88
+ // Title
89
+ output += `${result.title}\n`;
90
+
91
+ // Court and date
92
+ const parts: string[] = [];
93
+ if (result.court) parts.push(result.court);
94
+ if (result.date) parts.push(result.date);
95
+ if (parts.length > 0) {
96
+ output += `${parts.join(' | ')}\n`;
97
+ }
98
+
99
+ // Snippet if available
100
+ if (result.snippet) {
101
+ const cleanSnippet = result.snippet
102
+ .replace(/\s+/g, ' ')
103
+ .trim()
104
+ .substring(0, 200);
105
+ output += `> ${cleanSnippet}${cleanSnippet.length >= 200 ? '...' : ''}\n`;
106
+ }
107
+
108
+ // Add document links
109
+ output += `[View on TNA](${result.urls.web}) | [PDF](${result.urls.pdf})\n`;
110
+
111
+ output += '\n';
112
+ }
113
+
114
+ return output.trim();
115
+ }
116
+
117
+ // ============================================================================
118
+ // CASE CONTENT FORMATTER
119
+ // ============================================================================
120
+
121
+ export interface FormatOptions {
122
+ includeMetadata?: boolean;
123
+ paragraphRange?: string;
124
+ }
125
+
126
+ export function formatCaseContent(
127
+ caseData: CaseContent,
128
+ options: FormatOptions = {}
129
+ ): string {
130
+ const { includeMetadata = true, paragraphRange } = options;
131
+
132
+ let output = '';
133
+
134
+ // Metadata header
135
+ if (includeMetadata) {
136
+ output += `# ${caseData.metadata.title}\n\n`;
137
+
138
+ if (caseData.metadata.neutralCitation) {
139
+ output += `**Citation:** ${caseData.metadata.neutralCitation}\n`;
140
+ }
141
+
142
+ output += `**Court:** ${caseData.metadata.courtName || caseData.metadata.court}\n`;
143
+
144
+ if (caseData.metadata.date) {
145
+ output += `**Date:** ${caseData.metadata.date}\n`;
146
+ }
147
+
148
+ if (caseData.judges && caseData.judges.length > 0) {
149
+ output += `**Judges:** ${caseData.judges.join(', ')}\n`;
150
+ }
151
+
152
+ if (caseData.parties) {
153
+ if (caseData.parties.claimants.length > 0) {
154
+ output += `**Claimant(s):** ${caseData.parties.claimants.join(', ')}\n`;
155
+ }
156
+ if (caseData.parties.defendants.length > 0) {
157
+ output += `**Defendant(s):** ${caseData.parties.defendants.join(', ')}\n`;
158
+ }
159
+ }
160
+
161
+ // Add document links
162
+ output += `\n[View on TNA](${caseData.metadata.urls.web}) | [PDF](${caseData.metadata.urls.pdf})\n`;
163
+
164
+ output += '\n---\n\n';
165
+ }
166
+
167
+ // Apply paragraph range and truncation
168
+ const { paragraphs, truncated, remaining } = applyParaRange(
169
+ caseData.paragraphs,
170
+ paragraphRange
171
+ );
172
+
173
+ // Format paragraphs with numbers for citation
174
+ for (const para of paragraphs) {
175
+ output += `[${para.number}] ${para.text}\n\n`;
176
+ }
177
+
178
+ // Truncation notice
179
+ if (truncated) {
180
+ output += `---\n\n`;
181
+ output += `*Output truncated. ${remaining} paragraph${remaining === 1 ? '' : 's'} remaining. `;
182
+ const lastPara = paragraphs[paragraphs.length - 1];
183
+ if (lastPara) {
184
+ output += `Use the paragraphs parameter (e.g., "${lastPara.number + 1}-${lastPara.number + 50}") to retrieve more.*\n`;
185
+ }
186
+ }
187
+
188
+ return output.trim();
189
+ }
190
+
191
+ // ============================================================================
192
+ // CITATIONS FORMATTER
193
+ // ============================================================================
194
+
195
+ export function formatCitations(
196
+ citations: { citing: any[]; cited: any[] },
197
+ direction: 'citing' | 'cited' | 'both'
198
+ ): string {
199
+ let output = '';
200
+
201
+ if (direction === 'citing' || direction === 'both') {
202
+ output += `## Cases Citing This Decision\n\n`;
203
+ if (citations.citing.length === 0) {
204
+ output += `No citing cases found.\n\n`;
205
+ } else {
206
+ for (const c of citations.citing) {
207
+ output += `- **${c.citation || c.title}** (${c.court}, ${c.date})\n`;
208
+ }
209
+ output += '\n';
210
+ }
211
+ }
212
+
213
+ if (direction === 'cited' || direction === 'both') {
214
+ output += `## Cases Cited By This Decision\n\n`;
215
+ if (citations.cited.length === 0) {
216
+ output += `No cited cases found.\n\n`;
217
+ } else {
218
+ for (const c of citations.cited) {
219
+ output += `- **${c.citation || c.title}** (${c.court}, ${c.date})\n`;
220
+ }
221
+ output += '\n';
222
+ }
223
+ }
224
+
225
+ return output.trim();
226
+ }
package/src/search.ts ADDED
@@ -0,0 +1,108 @@
1
+ // src/search.ts
2
+ // ============================================================================
3
+ // UNIFIED SEARCH LAYER
4
+ // ============================================================================
5
+ //
6
+ // Combines results from:
7
+ // 1. TNA API (2003+)
8
+ // 2. Local PostgreSQL (pre-2003 BAILII content) - optional, when database configured
9
+ //
10
+ // Results are merged using Reciprocal Rank Fusion (RRF).
11
+ // ============================================================================
12
+
13
+ import { searchTna, COURT_CODE_MAP, LEGAL_AREA_COURTS } from './tna-client.js';
14
+ import type { SearchResult } from './types.js';
15
+
16
+ export interface SearchParams {
17
+ query: string;
18
+ legalArea?: string;
19
+ court?: string;
20
+ yearFrom?: number;
21
+ yearTo?: number;
22
+ limit?: number;
23
+ page?: number;
24
+ }
25
+
26
+ export async function searchCaseLaw(params: SearchParams): Promise<SearchResult[]> {
27
+ const limit = params.limit || 10;
28
+
29
+ // Determine which TNA courts to search
30
+ let tnaCourts: string[] | undefined;
31
+
32
+ if (params.court && params.court !== 'any') {
33
+ tnaCourts = COURT_CODE_MAP[params.court];
34
+ }
35
+
36
+ if (params.legalArea && params.legalArea !== 'any') {
37
+ const areaCourts = LEGAL_AREA_COURTS[params.legalArea];
38
+ if (areaCourts) {
39
+ // Intersect with court filter if both specified
40
+ if (tnaCourts) {
41
+ tnaCourts = tnaCourts.filter(c => areaCourts.includes(c));
42
+ } else {
43
+ tnaCourts = areaCourts;
44
+ }
45
+ }
46
+ }
47
+
48
+ // For MVP, only use TNA API
49
+ // TODO: Add local database search when PostgreSQL is configured
50
+ const tnaResults = await searchTna({
51
+ query: params.query,
52
+ courts: tnaCourts,
53
+ yearFrom: params.yearFrom,
54
+ yearTo: params.yearTo,
55
+ limit: limit,
56
+ page: params.page,
57
+ });
58
+
59
+ return tnaResults.slice(0, limit);
60
+ }
61
+
62
+ // ============================================================================
63
+ // RECIPROCAL RANK FUSION (for future use with multiple sources)
64
+ // ============================================================================
65
+ //
66
+ // Combines ranked lists from different sources.
67
+ // Each document gets a score based on its rank in each list:
68
+ // score = sum(1 / (k + rank)) for each list
69
+ //
70
+ // k=60 is a standard constant that prevents top results from
71
+ // dominating too heavily.
72
+ // ============================================================================
73
+
74
+ export function reciprocalRankFusion(
75
+ resultLists: SearchResult[][],
76
+ limit: number,
77
+ k: number = 60
78
+ ): SearchResult[] {
79
+ const scores = new Map<string, number>();
80
+ const docMap = new Map<string, SearchResult>();
81
+
82
+ for (const results of resultLists) {
83
+ for (let rank = 0; rank < results.length; rank++) {
84
+ const result = results[rank];
85
+ if (!result) continue;
86
+
87
+ const docId = result.documentUri || result.neutralCitation || result.title;
88
+
89
+ const currentScore = scores.get(docId) || 0;
90
+ scores.set(docId, currentScore + 1 / (k + rank + 1));
91
+
92
+ // Keep the first occurrence (usually has more complete metadata)
93
+ if (!docMap.has(docId)) {
94
+ docMap.set(docId, result);
95
+ }
96
+ }
97
+ }
98
+
99
+ // Sort by RRF score
100
+ const sortedIds = [...scores.entries()]
101
+ .sort((a, b) => b[1] - a[1])
102
+ .map(([id]) => id);
103
+
104
+ return sortedIds
105
+ .slice(0, limit)
106
+ .map(id => docMap.get(id))
107
+ .filter((r): r is SearchResult => r !== undefined);
108
+ }
package/src/server.ts ADDED
@@ -0,0 +1,227 @@
1
+ // src/server.ts
2
+ // ============================================================================
3
+ // UK CASE LAW MCP SERVER
4
+ // ============================================================================
5
+ //
6
+ // Provides Claude with tools to search and retrieve UK case law.
7
+ //
8
+ // Tools provided:
9
+ // - uklaw_search: Search across all case law
10
+ // - uklaw_get_case: Get full text of a specific case
11
+ // ============================================================================
12
+
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
15
+ import { z } from 'zod';
16
+
17
+ import { searchCaseLaw } from './search.js';
18
+ import { getCaseByUri, getCaseByCitation } from './cases.js';
19
+ import { formatSearchResults, formatCaseContent } from './formatters.js';
20
+ import type { SearchParams } from './search.js';
21
+
22
+ // ============================================================================
23
+ // SERVER INITIALIZATION
24
+ // ============================================================================
25
+
26
+ const server = new McpServer({
27
+ name: 'uk-case-law',
28
+ version: '1.0.0',
29
+ });
30
+
31
+ // ============================================================================
32
+ // TOOL: uklaw_search
33
+ // ============================================================================
34
+ //
35
+ // Primary search tool. Searches TNA API (2003+).
36
+ // ============================================================================
37
+
38
+ server.tool(
39
+ 'uklaw_search',
40
+ `Search UK case law across all courts and tribunals.
41
+
42
+ Returns ranked results with:
43
+ - Neutral citations (e.g., [2024] UKSC 1)
44
+ - Case titles
45
+ - Courts and dates
46
+ - Brief snippets
47
+
48
+ Use filters to narrow results by court, legal area, or date range.
49
+
50
+ Examples:
51
+ - "patent obviousness test" - finds patent validity cases
52
+ - "unfair dismissal procedure" - finds employment cases
53
+ - "breach of fiduciary duty director" - finds company law cases`,
54
+
55
+ {
56
+ query: z.string()
57
+ .min(2)
58
+ .describe('Search terms - legal concepts, party names, or keywords'),
59
+
60
+ legal_area: z.enum([
61
+ 'any',
62
+ 'intellectual_property',
63
+ 'commercial',
64
+ 'company',
65
+ 'employment',
66
+ 'property',
67
+ 'family',
68
+ 'criminal',
69
+ 'public_law',
70
+ 'immigration',
71
+ 'personal_injury'
72
+ ])
73
+ .default('any')
74
+ .describe('Filter by area of law'),
75
+
76
+ court: z.enum([
77
+ 'any',
78
+ 'supreme_court',
79
+ 'court_of_appeal',
80
+ 'high_court',
81
+ 'crown_court',
82
+ 'tribunals'
83
+ ])
84
+ .default('any')
85
+ .describe('Filter by court level'),
86
+
87
+ year_from: z.number()
88
+ .int()
89
+ .min(1800)
90
+ .max(2025)
91
+ .optional()
92
+ .describe('Earliest decision year'),
93
+
94
+ year_to: z.number()
95
+ .int()
96
+ .min(1800)
97
+ .max(2025)
98
+ .optional()
99
+ .describe('Latest decision year'),
100
+
101
+ limit: z.number()
102
+ .int()
103
+ .min(1)
104
+ .max(50)
105
+ .default(10)
106
+ .describe('Maximum results to return'),
107
+
108
+ page: z.number()
109
+ .int()
110
+ .min(1)
111
+ .default(1)
112
+ .describe('Page number for pagination. If results seem truncated or date range is not met, try next page.'),
113
+ },
114
+
115
+ async ({ query, legal_area, court, year_from, year_to, limit, page }) => {
116
+ try {
117
+ const params: SearchParams = {
118
+ query,
119
+ legalArea: legal_area === 'any' ? undefined : legal_area,
120
+ court: court === 'any' ? undefined : court,
121
+ yearFrom: year_from,
122
+ yearTo: year_to,
123
+ limit,
124
+ page,
125
+ };
126
+
127
+ const results = await searchCaseLaw(params);
128
+ const formatted = formatSearchResults(results);
129
+
130
+ return {
131
+ content: [{ type: 'text', text: formatted }]
132
+ };
133
+ } catch (error) {
134
+ return {
135
+ content: [{
136
+ type: 'text',
137
+ text: `Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`
138
+ }],
139
+ isError: true
140
+ };
141
+ }
142
+ }
143
+ );
144
+
145
+ // ============================================================================
146
+ // TOOL: uklaw_get_case
147
+ // ============================================================================
148
+ //
149
+ // Retrieves full text of a specific case. For TNA cases, fetches from API.
150
+ //
151
+ // Output includes numbered paragraphs for precise citation.
152
+ // ============================================================================
153
+
154
+ server.tool(
155
+ 'uklaw_get_case',
156
+ `Retrieve the full text of a specific UK case.
157
+
158
+ Accepts either:
159
+ - Neutral citation: "[2007] EWCA Civ 588"
160
+ - Document URI: "ewca/civ/2007/588"
161
+
162
+ Returns the judgment with numbered paragraphs. Use paragraph numbers
163
+ when citing specific passages, e.g., "as stated at [23]".
164
+
165
+ For long judgments, use the paragraphs parameter to request a specific
166
+ range (e.g., "1-50" for the first 50 paragraphs).`,
167
+
168
+ {
169
+ citation: z.string()
170
+ .describe('Neutral citation (e.g., "[2024] UKSC 1") or document URI (e.g., "uksc/2024/1")'),
171
+
172
+ paragraphs: z.string()
173
+ .optional()
174
+ .describe('Paragraph range to retrieve, e.g., "1-50" or "23-45". Omit for full text.'),
175
+
176
+ include_metadata: z.boolean()
177
+ .default(true)
178
+ .describe('Include case metadata (judges, date, court)')
179
+ },
180
+
181
+ async ({ citation, paragraphs, include_metadata }) => {
182
+ try {
183
+ // Determine if this is a neutral citation or URI
184
+ const isNeutralCitation = citation.startsWith('[');
185
+
186
+ const caseData = isNeutralCitation
187
+ ? await getCaseByCitation(citation)
188
+ : await getCaseByUri(citation);
189
+
190
+ if (!caseData) {
191
+ return {
192
+ content: [{ type: 'text', text: `Case not found: ${citation}` }],
193
+ isError: true
194
+ };
195
+ }
196
+
197
+ const formatted = formatCaseContent(caseData, {
198
+ includeMetadata: include_metadata,
199
+ paragraphRange: paragraphs,
200
+ });
201
+
202
+ return {
203
+ content: [{ type: 'text', text: formatted }]
204
+ };
205
+ } catch (error) {
206
+ return {
207
+ content: [{
208
+ type: 'text',
209
+ text: `Failed to retrieve case: ${error instanceof Error ? error.message : 'Unknown error'}`
210
+ }],
211
+ isError: true
212
+ };
213
+ }
214
+ }
215
+ );
216
+
217
+ // ============================================================================
218
+ // START SERVER
219
+ // ============================================================================
220
+
221
+ async function main() {
222
+ const transport = new StdioServerTransport();
223
+ await server.connect(transport);
224
+ console.error('UK Case Law MCP server running');
225
+ }
226
+
227
+ main().catch(console.error);