ONS + Nomis MCP server
# 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
Scored across 18 tools
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.
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.
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.
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.