nomis-mcp
# nomis-mcp
Query the [NOMIS API](https://www.nomisweb.co.uk/api/v01/help) — the ONS labour
market and census data service — from Claude. Two halves that are meant to be
installed together:
- **an MCP server**, six tools shaped around the discovery chain NOMIS actually
requires rather than a one-to-one mapping of its endpoints;
- **the `nomis-extract` skill**, which drives those tools: it turns a vague data
request into a confirmed spec before it fetches anything, carries the West of
England geography codes, and can emit the finished query as a reproducible
`nomisdata` R script.
The server without the skill works, but every session then re-derives the same
geography vintages and dataset quirks.
## Why hand-written tools
NOMIS publishes 1,617 datasets sharing 482 distinct dimension concepts. Only
`measures` and `freq` appear in all of them, `geography` in 1,586. No fixed
OpenAPI parameter list covers that, so generating tools from a spec cannot work.
The useful unit of work is a four-step chain: search datasets, read the
dimensions of one, resolve codes, fetch.
## Getting started
### 1. Install the server
```bash
git clone https://github.com/stevecrawshaw/nomis-mcp.git ~/projects/nomis-mcp
cd ~/projects/nomis-mcp
uv sync
```
### 2. Get an API key
Sign in at [nomisweb.co.uk](https://www.nomisweb.co.uk), open **My Account**, and
copy the unique ID — it starts with `0x`.
This matters more than it looks. Without a key you are an anonymous user capped
at 25,000 cells, and NOMIS enforces that cap by returning **HTTP 200 with a body
cut short and no error of any kind**. A query matching 1.7 million records comes
back as 25,000 rows that look complete.
```bash
cp .env.example .env # then paste the key into NOMIS_UID
```
Either variable works:
```bash
NOMIS_UID="0x..." # quote it, see below
NOMIS_CONFIG_FILE=~/projects/config.yml # or reuse the R project's config
```
`NOMIS_CONFIG_FILE` reads the same layout `nomis_codes.R` uses:
```yaml
nomis:
uid: "0x1a2b3c"
```
**Quote the value.** YAML 1.1 reads unquoted `0x1a2b3c` as the integer `1715004`,
which would send a mangled key and silently demote you to anonymous. The loader
recovers the literal token anyway, and there is a test for it, but quoting is
clearer.
### 3. Register the server with Claude Code
Copy `.mcp.json.example` to `.mcp.json` in whichever project should have NOMIS
access, then set the absolute path and your key:
```json
{
"mcpServers": {
"nomis": {
"command": "uv",
"args": ["run", "--directory", "/home/you/projects/nomis-mcp",
"python", "-m", "nomis_mcp.server"],
"env": {
"NOMIS_UID": "0x...",
"NOMIS_OUTPUT_DIR": "~/nomis-downloads"
}
}
}
}
```
`.mcp.json` is git-ignored here because it holds the key. For access from every
project instead of one, run `claude mcp add --scope user` with the same command.
### 4. Install the skill
```bash
bash scripts/install-skill.sh # symlink into ~/.claude/skills
bash scripts/install-skill.sh --copy # snapshot instead of symlink
bash scripts/install-skill.sh --project ~/work/x # one project only
```
The symlink is the default so a `git pull` here updates the skill in place. The
script also merges the `nomis-extract` entry into your `skill-rules.json`,
backing up the existing file, so its trigger keywords fire without you naming the
skill.
### 5. Check it works
Restart Claude Code, then ask it to run `check_auth`. That tool probes a known
large query and reports whether the cap is actually lifted, rather than trusting
that a key is present. Then try a real request:
> Claimant count for the West of England authorities, latest month
The skill should ask about topic, geography and timescale, show you a written
spec, and fetch only after you confirm it.
## Tools
| Tool | Purpose |
|---|---|
| `check_auth` | Report key status and empirically test the 25,000-cell cap |
| `search_datasets` | Find datasets by keyword, with status and last-updated |
| `get_dataset_dimensions` | List a dataset's filterable dimensions and geography types |
| `search_codes` | Resolve names to the opaque numeric codes fetches need |
| `fetch_data` | Fetch observations, capped and truncation-checked |
| `fetch_data_to_file` | Stream an unrestricted query to CSV on disc |
### Worked example
Claimant count for Bristol, latest month:
```python
search_datasets("claimant") # -> NM_1_1
get_dataset_dimensions("NM_1_1") # -> geography, time, sex, item, measures
search_codes("NM_1_1", "geography") # -> TYPE424 = local authorities (April 2023)
search_codes("NM_1_1", "geography", "bristol", "TYPE424")
# -> 1778384919, E06000023
fetch_data("NM_1_1", {"geography": "1778384919", "sex": "7",
"item": "1", "measures": "20100", "time": "latest"})
# -> 626, July 2026
```
Geography takes two steps deliberately. Searching the geography codelist without
a type returns nothing and reports success, because the top of that hierarchy
holds only a few country nodes. `search_codes` routes around this by returning
the type list instead.
## The skill
`skills/nomis-extract/` is the canonical copy.
| File | Loaded |
|---|---|
| `SKILL.md` | Always. The seven-step chain: scope, dataset, columns, filters, confirm, fetch, R script |
| `reference.md` | On geography, `MAKE` aggregates, or a `search_datasets` error |
| `r-script.md` | On step 7, when writing the `nomisdata` script |
`reference.md` is where the local knowledge lives: the four West of England
authority codes, why no single NOMIS code covers that footprint, the latest
boundary vintage per geography level, and the per-dataset traps (`NM_2014_1`
returns duplicate "All Ages" rows; `NM_162_1` rounds to the nearest 5). Add a
dataset quirk there each time you find one.
## Silent failures this server guards against
NOMIS answers many bad requests with HTTP 200. Each of these is handled
explicitly and has a test or an error path:
| Input | NOMIS response | Handling |
|---|---|---|
| Oversized query | 200, body truncated at the cap | `RECORD_COUNT` compared to rows returned, `truncated` flag plus a warning in the reply |
| Unknown dataset id | 200, `{"overview": {"id": "NM_NOPE"}}` | Absence of a name raises `ToolError` |
| Unknown dimension name | 200, empty body | Empty body raises `ToolError` naming the likely cause |
| Search without wildcards | 200, no matches | Wildcards added automatically |
| Geography search above a type | 200, empty codelist | Returns the geography type list instead |
One it does not: a `search_datasets` query matching nothing throws
`'NoneType' object has no attribute 'get'` from the API rather than returning an
empty list. The skill treats that error as "no results", not "bad query".
## Development
```bash
uv run pytest # 20 tests, fixtures captured from the live API 2026-08-29
uv run ruff check src tests
uv run mypy src
```
`parse.py` holds no network calls, so the SDMX and CSV handling is tested against
fixtures in `tests/fixtures/`.
## Scope
Exploratory querying inside Claude. The `nomisdata` R package remains the
analysis path; `fetch_data_to_file` and the generated R script are the handover
points. Not implemented: jsonstat output, spatial/KML fetch (1,000-cell cap),
response caching.
## Licence
MIT. See [LICENSE](LICENSE).
Last reviewed: 2026-08-29
TDQS
Scored across 6 tools
Each tool maps to a distinct stage of the NOMIS workflow: authentication, dataset discovery, dimension introspection, code lookup, inline data retrieval, and file-based retrieval. Even fetch_data and fetch_data_to_file are cleanly separated by output mode and result size, with explicit guidance on when to use each.
All six tools follow a consistent verb_noun pattern: check_auth, search_datasets, get_dataset_dimensions, search_codes, fetch_data, fetch_data_to_file. The one compound name still fits the same convention and is easy to predict.
Six tools is a well-scoped size for a read-only statistics API wrapper. Each tool earns its place and together they cover discovery, preparation, and retrieval without bloat.
The tool set covers the full read-only lifecycle: find datasets, inspect dimensions, resolve codes, fetch small results, and stream large results to file. No obvious dead ends or missing operations for the stated purpose of accessing NOMIS data.