# geosearch > UK place search API (towns, streets, postcodes, rivers, points of interest; typo-tolerant; "pubs in n5" style queries) with free self-hosted vector maps and an MCP server. A proof of concept by Dan Hartropp; keys from dan@danhartropp.com. ## Docs - [API](https://geo.danhartropp.com/docs/api.md): /search, /suggest, /reverse, parameters, response fields, and how to read ties and confidence - [MCP server](https://geo.danhartropp.com/docs/mcp.md): install commands and tools (search_places, describe_location, resolve_places, map_link) - [Maps](https://geo.danhartropp.com/docs/maps.md): MapLibre setup with Protomaps tiles, fonts, sprites and colour overrides ## Optional - [All docs in one file](https://geo.danhartropp.com/llms-full.txt) - [OpenAPI spec](https://geosearch-tlm25zrwea-nw.a.run.app/openapi.yaml) - [Agent skill](https://geo.danhartropp.com/skill/geosearch/SKILL.md): add UK place search and a map to an app --- # geosearch API UK place search: towns, villages, streets, postcodes, rivers, canals and points of interest (shops, pubs, hospitals, schools and so on). It's typo-tolerant and understands "thing in place" queries like "pubs in n5". Responses typically take a few milliseconds on the server. This is a proof of concept. Keys come from Dan Hartropp (dan@danhartropp.com). Base URL: `https://geosearch-tlm25zrwea-nw.a.run.app` Auth: header `X-API-Key: ` on every request. Limits: 600 requests a minute per key, with short bursts. A 429 response carries `Retry-After`. Attribution: every response has an `attribution` string. Show it wherever you show results. ## GET /search Find places from free text. Parameters: - `q` (required): the query, e.g. `manchestr`, `hospital in leeds`, `N5 1XL`, `Church Lane, Stowmarket`. - `limit`: 1–50, default 10. - `lat`, `lon`: optional, together. They prefer results near this point for queries that don't name a place ("pubs", "tesco", "pubs near me"). A query that names a place ignores them. - `geometry=true`: adds line geometry (GeoJSON MultiLineString) for streets, rivers and canals. Lines can be large; ask only when you'll draw them. Response: ```json { "query": "newport", "results": [ {"name": "Newport", "kind": "settlement", "county": "Casnewydd - Newport", "lat": 51.5879, "lon": -2.9967, "answer": true} ], "more": true, "confidence": 0.92, "attribution": "…" } ``` Result fields: `name`, `kind` (settlement, street, poi, feature, address, district, county, country, postcode), `category` (for POIs, e.g. "pub"), `town`, `county`, `lat`, `lon`, `answer` (true for right answers, which come first), `primary_name` (the other-language name where there is one), `geometry` (only with `geometry=true`). Response fields: `results`; `confidence` (0–1, how likely the first result is right; above 0.7 it reads as a probability); `more` (true when more right answers exist than were returned); `near` (the postcode used for `lat`/`lon`, when they were applied); `postcode` (when the query was a postcode). How to read results: - Take the first result with `answer: true`. - When several `answer: true` results share the same name in different places, they're equally good places with the same name, so ask the user which one they meant. Take `kind` into account: "nottingham" ties the city with pubs called The Nottingham, and the city is usually what's meant. - With no `answer: true` results, show the results as suggestions. ## GET /suggest Autocomplete as the user types (from about 3 characters). It matches the start of names, so it's quicker but less forgiving than /search; run /search on Enter. Parameters: `q` (required), `limit` 1–20 (default 10), `lat` + `lon`. Response: `{"query": "nott", "results": [{"name": "Nottingham", "kind": "settlement", "county": "City of Nottingham", "lat": 52.956, "lon": -1.151}], "attribution": "…"}` ## GET /reverse What's at a point. Parameters: `lat`, `lon` (required, within Great Britain and Northern Ireland), `kind` (comma list of settlement, street, feature, poi; default all), `limit` 1–50. Response: `postcode` (nearest, with `distance_m`), `areas` (`built_up_area`, `parish`, `ward`, `district`, `county`, `country`), `settlement`, `street`, and `results` (nearby places with `distance_m`). ## Errors `{"error": "message"}` with status 400 (bad parameter), 401 (missing or unknown key), 403 (key not allowed from this website), 429 (rate limit; see `Retry-After`), 503 (search took too long; retry). ## Examples ```sh curl -s 'https://geosearch-tlm25zrwea-nw.a.run.app/search?q=lufbra&limit=3' -H 'X-API-Key: YOUR_KEY' ``` ```js const res = await fetch('https://geosearch-tlm25zrwea-nw.a.run.app/search?q=' + encodeURIComponent(q), { headers: { 'X-API-Key': KEY }, }); const { results, attribution } = await res.json(); ``` ```python import requests r = requests.get("https://geosearch-tlm25zrwea-nw.a.run.app/search", params={"q": "pubs", "lat": 53.80, "lon": -1.55, "limit": 10}, headers={"X-API-Key": KEY}) ``` Full OpenAPI spec: https://geosearch-tlm25zrwea-nw.a.run.app/openapi.yaml --- # geosearch MCP server An MCP server for coding agents and LLM pipelines that need UK places: search, describe a point, resolve a list of messy place names, and link to a map. Status: live. URL: `https://geosearch-tlm25zrwea-nw.a.run.app/mcp` (Streamable HTTP, stateless: POST only, JSON replies, no session ID) Auth: header `X-API-Key: ` on every request. It uses the same key and rate limit as the API: one request per search or describe call, one per query for resolve_places, none for map_link. ## Install Claude Code: ```sh claude mcp add --transport http geosearch https://geosearch-tlm25zrwea-nw.a.run.app/mcp --header "X-API-Key: YOUR_KEY" ``` Cursor (`~/.cursor/mcp.json`), VS Code (`.vscode/mcp.json`, key `servers`, with `"type": "http"`), Windsurf (`~/.codeium/windsurf/mcp_config.json`, `serverUrl`): ```json {"mcpServers": {"geosearch": {"url": "https://geosearch-tlm25zrwea-nw.a.run.app/mcp", "headers": {"X-API-Key": "YOUR_KEY"}}}} ``` ## Tools - `search_places(query, limit?, lat?, lon?)`: `limit` 1–50, default 5. Returns the GET /search response. - `describe_location(lat, lon, kinds?, limit?)`: `kinds` is a list from settlement, street, feature, poi (default all). Returns the GET /reverse response. - `resolve_places(queries, lat?, lon?)`: up to 100 queries in one call. Returns `{rows: [{query, best, confidence, tied, more, error}], attribution}`, where `tied` holds up to three equally good right answers. The whole batch is charged against the rate limit up front, so it's admitted or refused as a unit. - `map_link(lat, lon, zoom?, markers?)`: `zoom` 5–19, default 14; `markers` is a list of `{lat, lon, label}`. Returns `{url}`, a map with pins to show a person where something is. ## Using the results - When several `answer: true` results share the same name in different places, they're equally good places, so ask the user which one they meant rather than guessing. Take `kind` into account (a city usually beats a pub with the same name). `more: true` means not all of them fitted. - Low `confidence` means a person (or you) should check the row. Above 0.7 the score reads as a probability; below that, check. - Show `attribution` with any results you display. --- # geosearch maps Free vector maps of the UK: Protomaps tiles in one PMTiles file, read with HTTP range requests. No API key is needed for the map itself. Ask Dan Hartropp (dan@danhartropp.com) to enable your website, localhost included. Base: `https://maps.danhartropp.com` - Tiles: `pmtiles://https://maps.danhartropp.com/UK.pmtiles` - Fonts: `https://maps.danhartropp.com/fonts/{fontstack}/{range}.pbf` (Noto Sans Regular, Medium, Italic) - Sprites: `https://maps.danhartropp.com/sprites/v4/{flavor}`, where the flavour is light, dark, white, grayscale or black - Attribution: "© OpenStreetMap contributors, Protomaps" ## MapLibre GL JS ```html
``` ## Styling Change colours by overriding flavour keys: `{ ...basemaps.namedFlavor('light'), water: '#9ec5e0', earth: '#f4f1ea' }`. Useful keys: `earth`, `background`, `water`, `park_a`, `park_b`, `wood_a`, `buildings`, `highway`, `major`, `minor_a`, `railway`, `city_label`. Remove points of interest with `.filter(l => l.id !== 'pois')`, or all labels with `.filter(l => l.type !== 'symbol')`.