Skip to main content
Glama
stevecrawshaw

nomis-mcp

README.md
# 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

A4.7/5.0

Scored across 6 tools

Disambiguation5/5

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.

Naming Consistency5/5

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.

Tool Count5/5

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.

Completeness5/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues