mcp-server-police-uk
Version:
MCP server for police.uk API - access UK crime data, police forces, neighbourhoods, and stop-and-search incidents
493 lines (454 loc) • 15.8 kB
text/typescript
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const BASE_URL = 'https://data.police.uk/api';
const REQUEST_TIMEOUT_MS = 15_000;
/**
* Perform a GET request against the police.uk API.
*
* `path` MUST already contain only trusted, URL-encoded segments (see the
* `enc()` helper and the zod validation on each tool). On any non-2xx
* response or network/timeout error this throws an Error with an
* actionable message, so the calling tool handler surfaces `isError: true`.
*/
async function makeApiRequest(
path: string,
query?: Record<string, string | number | undefined>
): Promise<unknown> {
const url = new URL(`${BASE_URL}/${path}`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
let response: Response;
try {
response = await fetch(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (error) {
if (error instanceof Error && error.name === 'TimeoutError') {
throw new Error(
`Request to the police.uk API timed out after ${REQUEST_TIMEOUT_MS / 1000}s (${url.pathname}). ` +
`Try a smaller area or a specific month (date=YYYY-MM).`
);
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Network error contacting the police.uk API (${url.pathname}): ${message}`);
}
if (!response.ok) {
switch (response.status) {
case 404:
throw new Error(
`No data found (HTTP 404). The police.uk API returns 404 when there are no results for the given ` +
`parameters (for example no crimes/outcomes for that location or month), or when an id does not exist. ` +
`Check the id and try a different date (YYYY-MM).`
);
case 429:
throw new Error(
`Rate limited (HTTP 429). Too many requests to the police.uk API. Wait a moment and retry.`
);
case 503:
throw new Error(
`Area too large (HTTP 503). The police.uk API refuses requests whose custom area or point contains more ` +
`than 10,000 results. Use a smaller polygon/radius or narrow the request with a specific month (date=YYYY-MM).`
);
default: {
let body = '';
try {
body = (await response.text()).slice(0, 300);
} catch {
/* ignore body read errors */
}
throw new Error(
`The police.uk API returned HTTP ${response.status} ${response.statusText} for ${url.pathname}.` +
(body ? ` Response: ${body}` : '')
);
}
}
}
return response.json();
}
/** URL-encode a single path segment (validated ids are still encoded defensively). */
function enc(segment: string): string {
return encodeURIComponent(segment);
}
/**
* Serialize a tool result to text. Arrays longer than 1000 items are truncated
* with a note. Small results (<50 array items, or any object) are pretty-printed;
* larger results are compact to save tokens.
*/
function formatResult(result: unknown): string {
let value = result;
let note = '';
if (Array.isArray(result) && result.length > 1000) {
value = result.slice(0, 1000);
note = `\n...truncated 1000 of ${result.length} items`;
}
const isSmall = Array.isArray(value) ? value.length < 50 : true;
return JSON.stringify(value, null, isSmall ? 2 : 0) + note;
}
function textResult(result: unknown) {
return { content: [{ type: 'text' as const, text: formatResult(result) }] };
}
// ---------------------------------------------------------------------------
// Reusable validators
// ---------------------------------------------------------------------------
const lat = z
.number()
.min(-90)
.max(90)
.describe('Latitude, between -90 and 90');
const lng = z
.number()
.min(-180)
.max(180)
.describe('Longitude, between -180 and 180');
const dateMonth = z
.string()
.regex(/^\d{4}-\d{2}$/, 'date must be in YYYY-MM format')
.describe('Limit results to a specific month, formatted as YYYY-MM');
const forceId = z
.string()
.regex(/^[a-zA-Z0-9-]+$/, 'force id may only contain letters, digits and dashes')
.describe('Police force identifier, e.g. "metropolitan" or "avon-and-somerset"');
const categoryId = z
.string()
.regex(/^[a-zA-Z0-9-]+$/, 'category may only contain letters, digits and dashes')
.describe('Crime category slug, e.g. "all-crime" or "burglary"');
const neighbourhoodId = z
.string()
.regex(/^[a-zA-Z0-9_-]+$/, 'neighbourhood id may only contain letters, digits, dashes and underscores')
.describe('Neighbourhood identifier within the force');
const persistentId = z
.string()
.regex(/^[a-fA-F0-9]{64}$/, 'persistent_id must be 64 hexadecimal characters')
.describe('The 64-character hexadecimal unique identifier for the crime');
const poly = z
.string()
.describe('lat/lng pairs defining a polygon boundary, e.g. "52.2,0.1:52.3,0.2:52.1,0.15"');
const locationId = z.number().int().describe('The numeric ID of an anonymised location');
const READ_ONLY = { readOnlyHint: true, openWorldHint: true } as const;
// ---------------------------------------------------------------------------
// Server
// ---------------------------------------------------------------------------
const server = new McpServer({
name: 'police-uk-api-tools',
version: '1.1.0',
});
server.registerTool(
'get_street_level_crimes',
{
description:
'Retrieve street-level crimes at a point (lat/lng, 1-mile radius) or within a custom polygon area. Provide either lat AND lng, or poly.',
inputSchema: {
lat: lat.optional(),
lng: lng.optional(),
poly: poly.optional(),
date: dateMonth.optional(),
category: categoryId.default('all-crime'),
},
annotations: READ_ONLY,
},
async ({ lat, lng, poly, date, category }) => {
const query: Record<string, string | number | undefined> = { date };
if (lat !== undefined && lng !== undefined) {
query.lat = lat;
query.lng = lng;
} else if (poly !== undefined) {
query.poly = poly;
} else {
throw new Error('Provide either lat and lng, or poly, to locate the crimes.');
}
return textResult(await makeApiRequest(`crimes-street/${enc(category)}`, query));
}
);
server.registerTool(
'get_street_level_outcomes',
{
description:
'Retrieve street-level outcomes by location ID, point (lat/lng), or custom polygon. Provide location_id, or lat AND lng, or poly.',
inputSchema: {
lat: lat.optional(),
lng: lng.optional(),
poly: poly.optional(),
location_id: locationId.optional(),
date: dateMonth.optional(),
},
annotations: READ_ONLY,
},
async ({ lat, lng, poly, location_id, date }) => {
const query: Record<string, string | number | undefined> = { date };
if (location_id !== undefined) {
query.location_id = location_id;
} else if (lat !== undefined && lng !== undefined) {
query.lat = lat;
query.lng = lng;
} else if (poly !== undefined) {
query.poly = poly;
} else {
throw new Error('Provide location_id, or lat and lng, or poly.');
}
return textResult(await makeApiRequest('outcomes-at-location', query));
}
);
server.registerTool(
'get_crimes_at_location',
{
description:
'Retrieve crimes at a specific anonymised location by ID, or at the location nearest to a point (lat/lng). Provide location_id, or lat AND lng.',
inputSchema: {
lat: lat.optional(),
lng: lng.optional(),
location_id: locationId.optional(),
date: dateMonth.optional(),
},
annotations: READ_ONLY,
},
async ({ lat, lng, location_id, date }) => {
const query: Record<string, string | number | undefined> = { date };
if (location_id !== undefined) {
query.location_id = location_id;
} else if (lat !== undefined && lng !== undefined) {
query.lat = lat;
query.lng = lng;
} else {
throw new Error('Provide location_id, or lat and lng.');
}
return textResult(await makeApiRequest('crimes-at-location', query));
}
);
server.registerTool(
'get_crimes_no_location',
{
description: 'Retrieve crimes for a force and category that could not be mapped to a location.',
inputSchema: {
category: categoryId.describe('Crime category slug, or "all-crime"'),
force: forceId,
date: dateMonth.optional(),
},
annotations: READ_ONLY,
},
async ({ category, force, date }) => {
return textResult(await makeApiRequest('crimes-no-location', { category, force, date }));
}
);
server.registerTool(
'get_crime_categories',
{
description: 'Retrieve the valid crime categories for a given month.',
inputSchema: { date: dateMonth.optional() },
annotations: READ_ONLY,
},
async ({ date }) => {
return textResult(await makeApiRequest('crime-categories', { date }));
}
);
server.registerTool(
'get_last_updated',
{
description: 'Retrieve the date when street-level crime data was last updated.',
inputSchema: {},
annotations: READ_ONLY,
},
async () => {
return textResult(await makeApiRequest('crime-last-updated'));
}
);
server.registerTool(
'get_outcomes_for_crime',
{
description: 'Retrieve the outcomes (case history) for a specific crime by its persistent ID.',
inputSchema: { persistent_id: persistentId },
annotations: READ_ONLY,
},
async ({ persistent_id }) => {
return textResult(await makeApiRequest(`outcomes-for-crime/${enc(persistent_id)}`));
}
);
server.registerTool(
'get_list_of_forces',
{
description: 'Retrieve a list of all police forces.',
inputSchema: {},
annotations: READ_ONLY,
},
async () => {
return textResult(await makeApiRequest('forces'));
}
);
server.registerTool(
'get_force_details',
{
description: 'Retrieve details for a specific police force.',
inputSchema: { force_id: forceId },
annotations: READ_ONLY,
},
async ({ force_id }) => {
return textResult(await makeApiRequest(`forces/${enc(force_id)}`));
}
);
server.registerTool(
'get_senior_officers',
{
description: 'Retrieve the senior officers for a specific police force.',
inputSchema: { force_id: forceId },
annotations: READ_ONLY,
},
async ({ force_id }) => {
return textResult(await makeApiRequest(`forces/${enc(force_id)}/people`));
}
);
server.registerTool(
'get_neighbourhoods',
{
description: 'Retrieve a list of neighbourhoods for a specific police force.',
inputSchema: { force_id: forceId },
annotations: READ_ONLY,
},
async ({ force_id }) => {
return textResult(await makeApiRequest(`${enc(force_id)}/neighbourhoods`));
}
);
server.registerTool(
'get_neighbourhood_details',
{
description: 'Retrieve details for a specific neighbourhood within a force.',
inputSchema: { force_id: forceId, neighbourhood_id: neighbourhoodId },
annotations: READ_ONLY,
},
async ({ force_id, neighbourhood_id }) => {
return textResult(await makeApiRequest(`${enc(force_id)}/${enc(neighbourhood_id)}`));
}
);
server.registerTool(
'get_neighbourhood_boundary',
{
description:
'Retrieve the boundary (list of lat/lng coordinates) for a specific neighbourhood. Can be large.',
inputSchema: { force_id: forceId, neighbourhood_id: neighbourhoodId },
annotations: READ_ONLY,
},
async ({ force_id, neighbourhood_id }) => {
return textResult(await makeApiRequest(`${enc(force_id)}/${enc(neighbourhood_id)}/boundary`));
}
);
server.registerTool(
'get_neighbourhood_team',
{
description: 'Retrieve the team members for a specific neighbourhood.',
inputSchema: { force_id: forceId, neighbourhood_id: neighbourhoodId },
annotations: READ_ONLY,
},
async ({ force_id, neighbourhood_id }) => {
return textResult(await makeApiRequest(`${enc(force_id)}/${enc(neighbourhood_id)}/people`));
}
);
server.registerTool(
'get_neighbourhood_events',
{
description: 'Retrieve events scheduled for a specific neighbourhood.',
inputSchema: { force_id: forceId, neighbourhood_id: neighbourhoodId },
annotations: READ_ONLY,
},
async ({ force_id, neighbourhood_id }) => {
return textResult(await makeApiRequest(`${enc(force_id)}/${enc(neighbourhood_id)}/events`));
}
);
server.registerTool(
'get_neighbourhood_priorities',
{
description: 'Retrieve policing priorities for a specific neighbourhood.',
inputSchema: { force_id: forceId, neighbourhood_id: neighbourhoodId },
annotations: READ_ONLY,
},
async ({ force_id, neighbourhood_id }) => {
return textResult(await makeApiRequest(`${enc(force_id)}/${enc(neighbourhood_id)}/priorities`));
}
);
server.registerTool(
'locate_neighbourhood',
{
description: 'Find the neighbourhood policing team responsible for a given latitude and longitude.',
inputSchema: { lat, lng },
annotations: READ_ONLY,
},
async ({ lat, lng }) => {
return textResult(await makeApiRequest('locate-neighbourhood', { q: `${lat},${lng}` }));
}
);
server.registerTool(
'get_stop_searches_by_area',
{
description:
'Retrieve stop-and-searches at a point (lat/lng, 1-mile radius) or within a custom polygon. Provide either lat AND lng, or poly.',
inputSchema: {
lat: lat.optional(),
lng: lng.optional(),
poly: poly.optional(),
date: dateMonth.optional(),
},
annotations: READ_ONLY,
},
async ({ lat, lng, poly, date }) => {
const query: Record<string, string | number | undefined> = { date };
if (lat !== undefined && lng !== undefined) {
query.lat = lat;
query.lng = lng;
} else if (poly !== undefined) {
query.poly = poly;
} else {
throw new Error('Provide either lat and lng, or poly.');
}
return textResult(await makeApiRequest('stops-street', query));
}
);
server.registerTool(
'get_stop_searches_by_location',
{
description: 'Retrieve stop-and-searches at a specific anonymised location by ID.',
inputSchema: { location_id: locationId, date: dateMonth.optional() },
annotations: READ_ONLY,
},
async ({ location_id, date }) => {
return textResult(await makeApiRequest('stops-at-location', { location_id, date }));
}
);
server.registerTool(
'get_stop_searches_no_location',
{
description: 'Retrieve stop-and-searches for a force that could not be mapped to a location.',
inputSchema: { force_id: forceId, date: dateMonth.optional() },
annotations: READ_ONLY,
},
async ({ force_id, date }) => {
return textResult(await makeApiRequest('stops-no-location', { force: force_id, date }));
}
);
server.registerTool(
'get_stop_searches_by_force',
{
description: 'Retrieve stop-and-searches reported by a specific force.',
inputSchema: { force_id: forceId, date: dateMonth.optional() },
annotations: READ_ONLY,
},
async ({ force_id, date }) => {
return textResult(await makeApiRequest('stops-force', { force: force_id, date }));
}
);
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Police UK API MCP Server running on stdio');
}
main().catch((error) => {
console.error('Fatal error starting Police UK API MCP Server:', error);
process.exit(1);
});