TLDR; I (claude) wrote six MCP tools for the NOMIS API rather than generating them from a spec, because NOMIS has 1,617 datasets and no shared parameter list to generate from. The server on its own still made Claude guess badly, so it ships with a skill that handles the geography codes and dataset traps. Both are at github.com/stevecrawshaw/nomis-mcp.

The business problem

We use NOMIS a lot in my team. Well others do, I use it only infrequently. When I do, I find it confusing and somewhat overwhelming as there are so many arcane codes, dimensions and dataset_id’s that it’s quite hard to get a working download quickly. I’m old and tired. I have overcome this with the nomisdata package in R for the regular datasets I access. However I have also been supporting colleagues in developing a regional indicator project which uses a fair few NOMIS datasets, and they are feeling the pain too.

So I decided to do something about it and build an MCP and skill to reduce the friction. I hope it might help you too.

Why not generate the tools?

NOMIS is the ONS labour market and census service. It is the source for claimant count, Annual Population Survey, census tables, population estimates, and the rest of what I pull for West of England analysis.

My first instinct was the rest-to-mcp adapter that I had used to experiment with creating an MCP for our open data portal. Point it at a spec, get a python MCP server. NOMIS doesn’t support that approach. The 1,617 datasets share 482 distinct dimension concepts between them, and only two of those, measures and freq, appear in all of them. geography covers 1,586. Everything else is per dataset. There is no fixed parameter list to generate against, and no spec that describes one.

So the unit of work is not an endpoint. It is a chain of four questions, always in the same order. Which dataset holds this? What dimensions does it have? What are the codes for the values I want? Now fetch. Six tools follow that paradigm:

Tool Purpose
check_auth Report key status and test the 25,000 cell cap
search_datasets Find datasets by keyword
get_dataset_dimensions List a dataset’s dimensions and geography types
search_codes Resolve names to the numeric codes a fetch operation needs
fetch_data Fetch observations checking truncation
fetch_data_to_file Stream a large query to CSV

The failure mode that counts

NOMIS answers a lot of bad requests with HTTP 200 - success.

Without an API key you are an anonymous user capped at 25,000 cells. NOMIS applies that cap by cutting the body short and telling you nothing. A query matching 1.7 million records comes back as 25,000 rows that look complete. Nothing in the response says otherwise. You find out when a total is wrong by two orders of magnitude, if you find out at all.

That is the whole reason check_auth exists, and why it probes a known large query instead of just checking that a key is present. The same pattern shows up elsewhere: an unknown dataset id returns 200 with an almost empty object, an unknown dimension name returns 200 with an empty body, and a geography search above a type level returns an empty codelist and reports success. The server turns each of those into an error or a flag rather than passing the silence through.

One I could not fix from the client side. A search_datasets query matching nothing throws 'NoneType' object has no attribute 'get' from the API instead of returning an empty list. The error means “no results”, not “bad query”. This is worth knowing before you conclude the data does not exist.

The server was not enough

With the tools installed, Claude could work the chain. It just worked it badly, and badly in the same way every session.

It would pick a boundary era at random when I wanted the current one. It would take “West of England” at face value and use the combined authority code, which covers three of the four authorities I need, because North Somerset has not joined yet - but for our analyses we include it for most purposes. That single substitution produces a number that is wrong, plausible, and hard to spot.

I didn’t want analysts to be frustrated by the friction, uncertainty and token wastage, so I asked claude to build a skill. I used the excellent skills from Matt Pocock to help me develop the skill.

So nomis-extract sits alongside the server and holds the local knowledge:

  • the four unitary authority codes, and why no single NOMIS code covers that footprint
  • the MAKE|name|code1;code2 syntax for building the four authority aggregate
  • the current vintage per geography level, LSOA 2021, wards May 2025, and so on
  • per dataset traps. NM_2014_1 returns duplicated “All Ages” rows, so you need distinct(). NM_162_1 rounds counts to the nearest 5, so a MAKE aggregate differs from a manual sum by a few units. measures and measure are different dimensions in the same dataset, which returns wrong numbers rather than an error.

Obviously the precise geographical constraints and configuration in this skill are tailored to the analysis needed in our team, but this could be easily changed to suit any combination of areas that is needed.

Not only does it hold local knowledge, it provides a logical and structured series of questions to help the analysts confirm the specification for the data needed, reducing uncertainty, duplication, download size and onward data transformation operations.

The skill also refuses to fetch until it has written the spec out and the user has confirmed. Dataset, one decision per dimension, columns, output. Most of my wasted NOMIS queries have been the right query against the wrong dataset, and reading it back before it runs catches that.

Once the data is downloaded as CSV I have a neat little duckdb skill which is invoked by the download of a csv and does some initial introspection to check whether the row numbers are right and the values are sane.

Handing back to R

The last step is a script. After a fetch, the skill offers to write the same query as standalone R using the nomisdata package, then runs it and checks the row count and sum(obs_value) against what the MCP returned. If the two do not match it fixes the script or says which figure diverged.

In the regional indicators project we are using a combination of R and Quarto for reporting. The final product is a Quarto book. Analysts are writing R code and I don’t want to break a link in the reproducibility chain by ingesting a dataset directly sourced through a separate operation of an LLM.Hence the R script produces a deterministic and reproducible process which can be integrated into the reporting pipeline. This approach reduces the friction for NOMIS discovery, but maintains reproducibility.

Installing it

git clone https://github.com/stevecrawshaw/nomis-mcp.git ~/projects/nomis-mcp
cd ~/projects/nomis-mcp
uv sync
bash scripts/install-skill.sh

The install script symlinks the skill into ~/.claude/skills so a git pull updates it in place, and merges its trigger keywords into skill-rules.json without touching the other skills. Register the server with the .mcp.json.example in the repo, get a key from your NOMIS account page, and ask for check_auth first. If it reports the cap is still on, everything after that is quietly truncated.


<
Previous Post
Migrating my dotfiles to chezmoi: one config for a work desktop and an AI-harness-testing laptop
>
Blog Archive
Archive of all previous blog posts