Skip to main content
Glama
brynmrgn

ONS + Nomis MCP server

by brynmrgn
README.md
# ONS + Nomis MCP server

An MCP server over two UK statistics platforms, exposed from one server:

1. **ONS beta *Customise My Data*** (`https://api.beta.ons.gov.uk/v1`) —
   headline economic and social time series (CPIH, regional GDP,
   wellbeing). Open, no key, rate-limited to 120 req/10s and 200/min.
2. **Nomis** (`https://www.nomisweb.co.uk/api/v01`) — census and
   labour-market statistics (Claimant Count, Annual Population Survey,
   Census 2011/2021 tables, workforce jobs). Open; an optional `uid`
   (`NOMIS_API_KEY`) lifts limits for large queries.

The two APIs share no dimension model, so the Nomis tools are namespaced
`nomis_*`. Rule of thumb: **ONS beta** for headline economic indicators,
**Nomis** for granular local-area, census and labour-market tables.

## Implementations

Two interchangeable implementations of the same 18-tool surface live here:

- **Python** (`ons_mcp/`, FastMCP) — the reference implementation.
- **.NET / C#** (`dotnet/`, ASP.NET Core + the MCP C# SDK) — a like-for-like
  port, and **what runs in production**.

Both expose the identical tools and behaviour. The live service is
`https://ons-mcp.library-apps.dev/mcp` (the .NET container, behind Cloudflare
Access). See [`dotnet/README.md`](dotnet/README.md) for the port and
[`docs/deploy.md`](docs/deploy.md) for deployment.

## Which route to use

**For headline indicators — use `get_timeseries`.** It serves the
published figure straight from the ONS website's series (the same number
ONS quotes), already chronologically ordered.

**For multi-dimensional slicing — use the CMD dataset tools.** They can
filter by geography, age, category etc. in ways the published series
can't.

> **CMD curation is uneven.** Some datasets track publication closely
> (`weekly-deaths-region`, updated within days); others lag badly
> (`cpih01` was five months behind the published CPIH series in July
> 2026; `regional-gdp-by-year` was three years behind). `get_dataset` and
> `search_datasets` return `last_updated` and a `staleness_warning` —
> check them before quoting a CMD figure.

Option codes can also change between versions (`cpih1dim1A0` in v1 became
`CP00` in v67). A wrong code returns **zero observations, not an error** —
`get_observations` now diagnoses this and lists valid codes.

## ONS beta API — data model

Datasets are held in tidy format. Every dataset has a `time` and a
`geography` dimension plus one or more topic dimensions. A dataset is
published as `versions`, grouped into `editions`. A single observation is
addressed by one option code per dimension; one dimension may be a `*`
wildcard to sweep a whole axis (e.g. a full time series) in one call.

## Tools

The tools are designed to be called in sequence, discovery-first:

| Tool | Purpose |
|------|---------|
| `get_timeseries` | **Published headline series by URI — prefer for latest rates** |
| `list_datasets` | Page through the catalogue |
| `search_datasets` | Client-side substring search over title/description/keywords |
| `get_dataset` | Resolve a dataset's latest edition + version |
| `get_dimensions` | List a version's variables |
| `get_options` | List valid option codes for one dimension |
| `get_observations` | Fetch observation(s); one dimension may be `*` |
| `get_metadata` | Provenance and bulk CSV/XLSX download links |
| `list_releases` | **Live release calendar** — upcoming/published/cancelled |
| `get_release` | One release: confirmed vs provisional date, related datasets |
| `list_topics` | Browse the ONS topic taxonomy (root, or a topic's subtopics) |
| `get_topic_content` | Publications under a topic; bridges series to `get_timeseries` |

Typical flow: `search_datasets("inflation")` → `get_dataset("cpih01")` →
`get_dimensions(...)` → `get_options(..., "aggregate")` →
`get_observations(..., {"time": "*", "geography": "K02000001",
"aggregate": "CP00"})`.

Geography uses ONS codes, e.g. `K02000001` = United Kingdom.

### Discovery: releases and topics

**Release calendar.** `list_releases(status="upcoming")` is the
authoritative schedule of ONS publications — use it for "when is the next
X out", **not** the `next_release` field on `get_timeseries`/`get_dataset`,
which is a snapshot from the last publication and can be stale (it reported
15 July when the calendar said 22 July). `get_release(uri)` gives one
entry's confirmed-or-provisional date, any cancellation notice, and the
datasets it will contain.

**Topics.** `list_topics()` returns the top-level taxonomy; pass a topic id
(`list_topics(parent_id="1245")`) to drill into subtopics.
`get_topic_content(topic_id)` lists the bulletins, articles and key series
under a topic — and fills in `timeseries_uri` on series items so you can
hand them straight to `get_timeseries`.

## Nomis tools

| Tool | Purpose |
|------|---------|
| `nomis_search_datasets` | Keyword search over the Nomis catalogue |
| `nomis_get_dataset` | Dataset overview + dimensions (with `conceptref`s) |
| `nomis_dimension_options` | Valid codes for a dimension (e.g. sex, age, measures) |
| `nomis_geography_search` | Resolve a place name to Nomis geography code(s) |
| `nomis_get_data` | Fetch observations (simple flat JSON) |
| `nomis_data_url` | Build a direct CSV/XLS/JSON bulk-download URL |

Nomis uses **internal integer codes**, not ONS mnemonics, and they differ
per dataset — always resolve via `nomis_dimension_options` /
`nomis_geography_search` first. Data retrieval uses Nomis' *simple* JSON
format (flat records with named dimensions), avoiding SDMX positional-key
decoding. `measures=20100` (value) is usually required.

Typical flow: `nomis_search_datasets("claimant")` →
`nomis_get_dataset("NM_1_1")` → `nomis_geography_search("NM_1_1",
"Birmingham")` and `nomis_dimension_options("NM_1_1", "sex")` →
`nomis_get_data("NM_1_1", {"geography": "...", "sex": "5,6,7",
"time": "latest", "measures": "20100"})`.

## Install

```sh
python -m venv .venv && . .venv/bin/activate
pip install -e .
```

## Run

Stdio (default, for desktop clients):

```sh
ons-mcp
```

Streamable HTTP (for a self-hosted deployment behind a tunnel), served at
`/mcp`:

```sh
ONS_MCP_HTTP=1 ONS_MCP_PORT=8095 ons-mcp
```

Bound to `127.0.0.1` by design — front it with Cloudflare Tunnel rather
than exposing the port. In production it runs at
`https://ons-mcp.library-apps.dev/mcp`, fronted by Cloudflare Access
(Managed OAuth), with the origin re-verifying the Access JWT for defence in
depth. See [`docs/deploy.md`](docs/deploy.md) for the systemd unit, tunnel
ingress and auth setup.

## Verify

`scripts/verify.py` exercises both halves — the published-series route
(`get_timeseries`) and the CMD route (`search` → `observations`, with
staleness and the invalid-code diagnosis) — through the real in-memory MCP
dispatch path against the live ONS API, plus the Cloudflare Access guard
wiring:

```sh
python scripts/verify.py
```

## Client config (stdio)

```json
{
  "mcpServers": {
    "ons-beta": {
      "command": "ons-mcp"
    }
  }
}
```

## Environment

| Variable | Default | Meaning |
|----------|---------|---------|
| `ONS_API_BASE` | `https://api.beta.ons.gov.uk/v1` | API root |
| `ONS_API_TIMEOUT` | `30` | Per-request timeout (s) |
| `ONS_MCP_HTTP` | unset | Set to `1` for HTTP transport (serves `/mcp`) |
| `ONS_MCP_PORT` | `8095` | HTTP port (bound to `127.0.0.1`) |
| `ONS_MCP_ALLOWED_HOSTS` | `ons-mcp.library-apps.dev,127.0.0.1,localhost` | DNS-rebinding Host allowlist |
| `CF_ACCESS_TEAM_DOMAIN` | unset | Cloudflare Access team domain; enables origin-side JWT checks (with `CF_ACCESS_AUD`) |
| `CF_ACCESS_AUD` | unset | Access application AUD; both must be set to enforce auth |
| `NOMIS_API_BASE` | `https://www.nomisweb.co.uk/api/v01` | Nomis API root |
| `NOMIS_API_KEY` | unset | Optional Nomis `uid` for larger queries |
| `NOMIS_API_TIMEOUT` | `60` | Nomis per-request timeout (s) |

## Notes

The beta API has no server-side text search, so `search_datasets` pages
the catalogue and filters locally. It's fine for a few hundred datasets
but does one request per 100 items; cache the result if you call it often.

The ONS beta tools are verified end-to-end against the live API by
`scripts/verify.py` (see **Verify** above). The Nomis tools were built
against the documented SDMX-JSON structure and unit-tested for parsing, but
not smoke-tested against the live Nomis service — validate the first few
`nomis_*` calls in your environment.

Licence: the code is MIT — see [LICENSE](LICENSE). The data it serves
contains public sector information licensed under the Open Government
Licence v3.0.

TDQS

A3.9/5.0

Scored across 18 tools

Disambiguation5/5

Tools are clearly segmented into ONS and Nomis groups by prefix, and within each group the purposes are distinct: metadata retrieval, dimension listing, data fetching, etc. The only slight overlap is between get_timeseries and get_observations, but they are explicitly documented for different use cases.

Naming Consistency4/5

ONS tools follow a consistent verb_noun pattern (get_, list_, search_), while Nomis tools use a consistent nomis_verb pattern. However, 'nomis_data_url' breaks the verb pattern, and mixed verb initiatives (get vs list) across the server are minor inconsistencies.

Tool Count5/5

18 tools is a reasonable count for covering two distinct APIs (ONS and Nomis) without being overwhelming. Each tool serves a clear purpose, and the number aligns well with the server's scope of data query and exploration.

Completeness4/5

The tool surface covers core operations for both APIs: browse, search, metadata, dimension options, data retrieval, and even a download URL builder. Minor gaps exist, such as lacking a tool to list all Nomis datasets without a search query, but this does not critically hinder functionality.

Maintenance

ActivityStale
ResponsivenessNo issues