writing · mcp
building a uk land registry mcp server for claude
thirty years of every property sale in england and wales, handed to a model as eighteen tools — and the four times it had to be taught to say no.
the problem
the data is public. the shape is hostile.
hm land registry publishes an extraordinary amount of open data. every residential property sale in england and wales since 1995 — around thirty million transactions. the official house price index, back to 1968. which companies own registered title, including the overseas ones. the boundary polygon of most registered parcels. all of it free, all of it licensed for reuse.
none of it is shaped like something you can ask a question of. the sold-price data lives behind a sparql endpoint over a linked-data cube, so a question as ordinary as "what did this house sell for" is a graph pattern with optional projections. the ownership and boundary data lives somewhere else entirely, behind an api that does not answer questions at all — it hands you monthly zip files and a signed url that expires in about ten seconds.
that gap is the whole project. an mcp server is a good fit for it precisely because the awkwardness is mechanical rather than conceptual: the questions people want to ask are simple, and everything hard sits between the question and the endpoint. so put the hard part in tools, and let the model ask in english.
the architecture
two tiers, because one of the apis cannot be queried
the server ended up split down the middle, and the split is forced by the publishers rather than chosen.
| tier | covers | setup |
|---|---|---|
| live | price paid data, house price index, postcode lookup | none — queries public endpoints directly |
| cached | ownership, boundaries, leases, covenants | free api key, plus a one-off download per dataset |
the *use land and property data* service has no query endpoint. it serves bulk files. so corporate ownership, overseas ownership, inspire polygons, leases and covenants all have to come down once and be queried locally. the server pulls them into an embedded duckdb database under ~/.hmlr-mcp — a few hundred megabytes for the uk corporate file, a hundred thousand rows for the overseas one.
downloading is deliberately two calls rather than one. the first reports the file name and size and fetches nothing; only the second commits. a model that has just been told "several hundred megabytes" behaves noticeably better about asking first than one that discovers it afterwards.
the live tier needs no key and no download. that matters more than it sounds — it means someone can install the server and get a real answer about a real address inside a minute, before deciding whether the rest is worth the setup.
the surface
eighteen tools, four questions
the tool list reads long, but it collapses into four questions people actually ask about a property: what did it sell for, how have prices moved, who owns it, and what surrounds it. the utilities exist so the model can find its own footing — a postcode lookup, a status tool that reports what is cached and what to do next, and the downloader.
| group | tools | tier |
|---|---|---|
| price paid data | search, property history, area stats | live |
| house price index | index, compare regions, adjust a price | live |
| ownership | by company, by area, overseas summary, by title | cached |
| boundaries | title polygon, adjacent parcels, parcels in area | cached |
| due diligence | leasehold check, covenant check | cached |
| utilities | postcode lookup, data status, download dataset | mixed |
the useful design rule was that a tool which cannot answer should say what would make it able to. if the ownership data is not cached, the tool does not fail with a shrug — it names the exact call that would fix it. the model reliably makes that call, tells the user what it is about to download, and carries on. no prompt engineering was needed to get that behaviour; it fell out of writing honest error messages.
the interesting part
teaching it when to refuse
the hard part of this server was never fetching data. it was the four places where a plausible answer would have been a wrong one, and the server has to decline instead.
the first is breadth. price paid data holds about thirty million transactions, and a search with no selective filter will not fail fast — it will sit there until something times out. so a search that cannot be made narrow is refused locally, before it is sent, with a message saying what would make it valid: a postcode, or a street and a town, or a town and a date range.
the second is the meaning of nothing. ownership data covers companies only — property held by private individuals is not published in bulk at all. so an empty ownership result means "no company owns registered title here". it does not mean unowned, and it does not mean unregistered. a model that reports "no owner found" as "this land has no owner" has invented something, and the tool descriptions say so in as many words.
the third is category. hm land registry classifies sales as standard — arm's-length residential at full market value — or additional, which sweeps in repossessions, transfers to companies, buy-to-lets and commercial deals. the additional category skews averages violently: a single £11.3m commercial sale in one postcode sector moves the mean by an order of magnitude. so area statistics default to standard-only, and the raw transaction search returns both but says which is which.
the fourth is the one below, and it is the one that catches people out.
the wall
the £20,000 join you are not allowed to make
the obvious thing to build, once you have boundary polygons and ownership records in the same database, is "who owns this parcel of land". you cannot. not with open data, and not by being clever.
inspire polygons are keyed on an inspire id. ownership records are keyed on a title number. hm land registry does not publish the mapping between them — that link is the national polygon service, which costs £20,000 plus vat a year. the two identifiers are not even the same shape: title numbers are two or three letters and some digits, inspire ids are bare integers. they can never coincidentally match.
the two keys, side by side
title number CS72510 ← ownership records
inspire id 52288545 ← boundary polygons
# no shared key. no derivation. the mapping is a paid product.so the adjacency tool does something weaker and says so. it reverse-geocodes each parcel's centroid to a postcode, then asks which companies own registered title at that postcode. that genuinely answers "which companies are active around here", which is the question someone assembling a site is asking. it does not answer "who owns this parcel", and the tool refuses to be read that way:
- a postcode usually covers several titles, so a match is a neighbourhood signal, not an ownership claim.
- the centroid resolves to the nearest postcode, which for a large or rural parcel may not be its own.
- bare land often has no postcode recorded at all, so it never matches.
- an empty result means nothing matched — not that the land is individually owned.
for the actual owner of a specific title there is a real answer: query by title number, or buy an official copy of the register for £3. the server says that too, rather than leaving the model to improvise a substitute.
around 12% of land in england and wales is still unregistered. it has no title and no polygon, so it is absent rather than empty — another place where "nothing found" and "nothing there" are different sentences.
under it
the same query, 250× apart
one performance detail was worth more than every other optimisation combined. the sparql endpoint evaluates patterns roughly in the order they are written. put the postcode filter after the optional blocks and it walks thirty million rows before narrowing. put it first and the same question — the same answer — returns immediately.
| query | time | result |
|---|---|---|
| selective filter written last | > 70s | times out |
| selective filter written first | 0.27s | identical answer |
so the query builder emits selective triples first, drops the type triple entirely — it matches every row, so it costs and buys nothing — and keeps optional projections last. the ordering is asserted in the test suite, because it is invisible in review and nothing else would catch it regressing.
run it yourself
setup, from nothing
it is not on npm yet, so there is no one-line install. clone it, build it once, and point your client at the built entry point. you need node 22 or later and nothing else — no docker, no python, no account.
clone and build
git clone https://github.com/light-vp/uk-land-registry-mcp.git
cd uk-land-registry-mcp
npm install
npm run buildclaude code — run from the repo directory
claude mcp add land-registry -- node "$PWD/dist/index.js"that is the whole setup for sold prices, the house price index and postcode lookups. ask it how the middlesbrough housing market has moved over the last five years and you should get a table back. the ownership and boundary tools need a free key from use-land-property-data.service.gov.uk — and note that the licence is accepted per dataset, which is the cause of very nearly every 403 people hit.