statsmapped-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ name: Publish to PyPI
2
+
3
+ # Triggers on creating a GitHub Release (developer's choice, 2026-09-20) -- draft a
4
+ # release with a version tag and a line or two of notes, and this builds and publishes
5
+ # automatically. Uses PyPI's Trusted Publishing (OIDC): no API token is stored as a
6
+ # secret anywhere -- PyPI trusts this exact repo + workflow file + environment
7
+ # combination directly, configured once on PyPI's own "Publishing" settings page.
8
+
9
+ on:
10
+ release:
11
+ types: [published]
12
+
13
+ jobs:
14
+ build:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.x"
21
+ - name: Build sdist and wheel
22
+ run: |
23
+ python -m pip install --upgrade pip build
24
+ python -m build
25
+ - uses: actions/upload-artifact@v4
26
+ with:
27
+ name: dist
28
+ path: dist/
29
+
30
+ publish:
31
+ needs: build
32
+ runs-on: ubuntu-latest
33
+ # Matches the "Environment name" field on PyPI's trusted-publisher form -- set that
34
+ # field to "pypi" too (or change both to match) when registering the publisher.
35
+ environment: pypi
36
+ permissions:
37
+ id-token: write # required for OIDC trusted publishing; no other permission needed
38
+ steps:
39
+ - uses: actions/download-artifact@v4
40
+ with:
41
+ name: dist
42
+ path: dist/
43
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .venv/
6
+ venv/
7
+ dist/
8
+ build/
9
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StatsMapped
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,145 @@
1
+ Metadata-Version: 2.5
2
+ Name: statsmapped-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing StatsMapped's public data API for Ireland and the UK (housing, crime, health, economy, social welfare, by county/local authority) as tools for AI agents.
5
+ Project-URL: Homepage, https://statsmapped.com
6
+ Project-URL: API docs, https://statsmapped.com/openapi.json
7
+ Project-URL: Source, https://github.com/ActiveGuy/statsmapped-mcp
8
+ Author-email: StatsMapped <feedback@statsmapped.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ireland,mcp,model-context-protocol,open-data,statistics,uk,united-kingdom
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: mcp<3,>=2.0.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # statsmapped-mcp
23
+
24
+ An [MCP](https://modelcontextprotocol.io) server exposing [StatsMapped](https://statsmapped.com)'s
25
+ public API as tools for AI agents (Claude Desktop, Cursor, and any other MCP client).
26
+
27
+ StatsMapped tracks public data for Ireland (by county) and the UK (by local authority) — housing,
28
+ crime, health, the economy and social welfare — from official publishers (CSO, PSRA, Central Bank
29
+ of Ireland, DHLGH, NTPF, the Office of Government Procurement, EU Publications Office for Ireland;
30
+ ONS/HM Land Registry/Nomis/DfE/DfT for the UK), each figure carrying its own caveats. This
31
+ package lets an agent query that data directly as tool calls instead of crawling and parsing
32
+ web pages. Every tool below takes a `country` argument (`"ireland"` or `"united-kingdom"`,
33
+ default `"ireland"`) — the two countries track genuinely different datasets and geography levels,
34
+ so call `list_datasets`/`list_areas` for the country you actually want rather than assume
35
+ Ireland's defaults apply.
36
+
37
+ This is a thin client. It calls StatsMapped's already-public, unauthenticated HTTPS API
38
+ (documented at [statsmapped.com/openapi.json](https://statsmapped.com/openapi.json)); no API key
39
+ is needed for anything below. Runs on your own machine over stdio by default (the recommended way
40
+ to use it today) -- see [Why stdio](#why-stdio-not-a-hosted-server-by-default) for a hosted
41
+ `streamable-http` mode this package also supports, and the real tradeoff that comes with it.
42
+
43
+ ## Install
44
+
45
+ Not yet published to PyPI. Once it is, install will be:
46
+
47
+ ```bash
48
+ pip install statsmapped-mcp
49
+ ```
50
+
51
+ ## Configure
52
+
53
+ Add to your MCP client's config (for Claude Desktop, `claude_desktop_config.json`; Cursor uses an
54
+ equivalent `mcp.json`):
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "statsmapped": {
60
+ "command": "statsmapped-mcp"
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ ## Tools
67
+
68
+ Every tool takes a `country` argument (`"ireland"` or `"united-kingdom"`, default `"ireland"`) —
69
+ Ireland and the UK track different datasets and geography levels, so call `list_datasets`/
70
+ `list_areas` for the country you want rather than assume Ireland's defaults apply.
71
+
72
+ - **`list_datasets(country="ireland")`** — every stat StatsMapped tracks for one country, with its
73
+ key, label, and which geography levels it's published at. Start here.
74
+ - **`list_areas(level="county", country="ireland")`** — every geography at one boundary level.
75
+ `level` defaults to Ireland's 26 counties; the UK's own primary level is `"lad"` (local
76
+ authority districts), not `"county"` — other levels exist per country too (Ireland's
77
+ `local_authority`/`garda_division` among them).
78
+ - **`list_area_datasets(area_id, country="ireland")`** — every dataset available for one area
79
+ (e.g. `"county:kerry"` for Ireland, `"uk:lad:e09000033"` for the UK), with its latest figure
80
+ and year-on-year change. Caveats are projected to label + severity only, not full text — the
81
+ point is deciding which datasets matter before paying for the full detail on any one of them.
82
+ - **`get_dataset_for_area(area_id, dataset, history_months=0, country="ireland")`** — full detail
83
+ on one dataset in one area: a written summary, full caveat text, and (optionally) recent
84
+ history.
85
+ - **`rank_areas(stat_key, level="county", country="ireland")`** — every area at one level, ranked
86
+ by its latest figure for one stat, highest first.
87
+ - **`list_comparisons(country="ireland")`** — every registered cross-dataset comparison pair for
88
+ one country (e.g. "median sale price vs new dwelling completions per 1,000 residents"). A
89
+ small, hand-curated set, not an arbitrary-pair engine.
90
+ - **`get_comparison(pair_key, country="ireland")`** — full detail for one registered pair: each
91
+ axis's label/unit/publisher, the correlation stats (r, rho, a leave-one-out sensitivity range),
92
+ and caveats. `pair_key` comes from `list_comparisons(country=...)` for the same country.
93
+ - **`check_comparability(stat_key_a, stat_key_b, country="ireland")`** — does StatsMapped have a
94
+ registered, hand-vetted comparison between these two stats? Registry-backed only — never
95
+ computes a fresh correlation for an arbitrary pair; `comparable: false` is a normal result for
96
+ most pairs, not an error.
97
+ - **`explain_metric(stat_key, country="ireland")`** — definition, methodology and standing caveats
98
+ for one stat, never a current figure. Use this when the question is about what a metric means or
99
+ how it's measured, not about one area's value.
100
+
101
+ ## Why stdio, not a hosted server, by default
102
+
103
+ StatsMapped runs on a single free-tier instance. A remote MCP endpoint hosted there would let an
104
+ agent's own multi-area query pattern (calling the same tool once per area, in a loop) reproduce
105
+ exactly the load pattern that has already caused timeouts on that instance under a large
106
+ geography fan-out. Running over stdio means every call goes through your own network connection
107
+ to the same public HTTPS API this package's tools call directly, with no shared bottleneck --
108
+ each user's own machine makes the HTTP calls, so N users' traffic is naturally spread across N
109
+ source IPs, not funnelled through one.
110
+
111
+ `server.py` also supports a real hosted `streamable-http` mode (`MCP_TRANSPORT=streamable-http`)
112
+ for a deployment that accepts that tradeoff -- StatsMapped's public API is itself rate-limited
113
+ per source IP (600 requests/hour), but a hosted MCP endpoint proxies every remote user's calls
114
+ through ONE shared egress IP, so all remote users of a hosted endpoint would share that one
115
+ bucket rather than each getting their own. A StatsMapped-hosted endpoint is live at `https://mcp.statsmapped.com`
116
+ (Streamable HTTP) -- confirmed responding correctly, no separate install needed for a client
117
+ that speaks Streamable HTTP directly.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ pip install -e .
123
+ python tests/test_client.py
124
+ ```
125
+
126
+ The test suite runs against the real live API (`https://statsmapped.com` by default, or
127
+ `STATSMAPPED_MCP_BASE_URL` if set) — read-only GETs only, nothing here writes any data or needs
128
+ a key.
129
+
130
+ ## Releasing
131
+
132
+ Publishing to PyPI happens automatically via `.github/workflows/publish.yml` on creating a
133
+ GitHub Release — no API token is stored anywhere. It uses
134
+ [PyPI's Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC): PyPI is told,
135
+ once, to trust this exact repo + workflow file + GitHub environment (`pypi`) combination, via
136
+ PyPI's own "Publishing" settings page under this project. To release: bump `version` in
137
+ `pyproject.toml`, commit, then draft a GitHub Release with a matching tag (e.g. `v0.2.0`).
138
+
139
+ ## Licence
140
+
141
+ MIT for this package. The underlying data keeps each publisher's own licence — see
142
+ [statsmapped.com/ireland/sources](https://statsmapped.com/ireland/sources) for Ireland's own
143
+ publisher/licence detail before reusing any figure outside of querying it through an agent (a UK
144
+ equivalent page doesn't exist yet — check each UK tool response's own `caveats`/`sources` fields
145
+ in the meantime).
@@ -0,0 +1,124 @@
1
+ # statsmapped-mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server exposing [StatsMapped](https://statsmapped.com)'s
4
+ public API as tools for AI agents (Claude Desktop, Cursor, and any other MCP client).
5
+
6
+ StatsMapped tracks public data for Ireland (by county) and the UK (by local authority) — housing,
7
+ crime, health, the economy and social welfare — from official publishers (CSO, PSRA, Central Bank
8
+ of Ireland, DHLGH, NTPF, the Office of Government Procurement, EU Publications Office for Ireland;
9
+ ONS/HM Land Registry/Nomis/DfE/DfT for the UK), each figure carrying its own caveats. This
10
+ package lets an agent query that data directly as tool calls instead of crawling and parsing
11
+ web pages. Every tool below takes a `country` argument (`"ireland"` or `"united-kingdom"`,
12
+ default `"ireland"`) — the two countries track genuinely different datasets and geography levels,
13
+ so call `list_datasets`/`list_areas` for the country you actually want rather than assume
14
+ Ireland's defaults apply.
15
+
16
+ This is a thin client. It calls StatsMapped's already-public, unauthenticated HTTPS API
17
+ (documented at [statsmapped.com/openapi.json](https://statsmapped.com/openapi.json)); no API key
18
+ is needed for anything below. Runs on your own machine over stdio by default (the recommended way
19
+ to use it today) -- see [Why stdio](#why-stdio-not-a-hosted-server-by-default) for a hosted
20
+ `streamable-http` mode this package also supports, and the real tradeoff that comes with it.
21
+
22
+ ## Install
23
+
24
+ Not yet published to PyPI. Once it is, install will be:
25
+
26
+ ```bash
27
+ pip install statsmapped-mcp
28
+ ```
29
+
30
+ ## Configure
31
+
32
+ Add to your MCP client's config (for Claude Desktop, `claude_desktop_config.json`; Cursor uses an
33
+ equivalent `mcp.json`):
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "statsmapped": {
39
+ "command": "statsmapped-mcp"
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ ## Tools
46
+
47
+ Every tool takes a `country` argument (`"ireland"` or `"united-kingdom"`, default `"ireland"`) —
48
+ Ireland and the UK track different datasets and geography levels, so call `list_datasets`/
49
+ `list_areas` for the country you want rather than assume Ireland's defaults apply.
50
+
51
+ - **`list_datasets(country="ireland")`** — every stat StatsMapped tracks for one country, with its
52
+ key, label, and which geography levels it's published at. Start here.
53
+ - **`list_areas(level="county", country="ireland")`** — every geography at one boundary level.
54
+ `level` defaults to Ireland's 26 counties; the UK's own primary level is `"lad"` (local
55
+ authority districts), not `"county"` — other levels exist per country too (Ireland's
56
+ `local_authority`/`garda_division` among them).
57
+ - **`list_area_datasets(area_id, country="ireland")`** — every dataset available for one area
58
+ (e.g. `"county:kerry"` for Ireland, `"uk:lad:e09000033"` for the UK), with its latest figure
59
+ and year-on-year change. Caveats are projected to label + severity only, not full text — the
60
+ point is deciding which datasets matter before paying for the full detail on any one of them.
61
+ - **`get_dataset_for_area(area_id, dataset, history_months=0, country="ireland")`** — full detail
62
+ on one dataset in one area: a written summary, full caveat text, and (optionally) recent
63
+ history.
64
+ - **`rank_areas(stat_key, level="county", country="ireland")`** — every area at one level, ranked
65
+ by its latest figure for one stat, highest first.
66
+ - **`list_comparisons(country="ireland")`** — every registered cross-dataset comparison pair for
67
+ one country (e.g. "median sale price vs new dwelling completions per 1,000 residents"). A
68
+ small, hand-curated set, not an arbitrary-pair engine.
69
+ - **`get_comparison(pair_key, country="ireland")`** — full detail for one registered pair: each
70
+ axis's label/unit/publisher, the correlation stats (r, rho, a leave-one-out sensitivity range),
71
+ and caveats. `pair_key` comes from `list_comparisons(country=...)` for the same country.
72
+ - **`check_comparability(stat_key_a, stat_key_b, country="ireland")`** — does StatsMapped have a
73
+ registered, hand-vetted comparison between these two stats? Registry-backed only — never
74
+ computes a fresh correlation for an arbitrary pair; `comparable: false` is a normal result for
75
+ most pairs, not an error.
76
+ - **`explain_metric(stat_key, country="ireland")`** — definition, methodology and standing caveats
77
+ for one stat, never a current figure. Use this when the question is about what a metric means or
78
+ how it's measured, not about one area's value.
79
+
80
+ ## Why stdio, not a hosted server, by default
81
+
82
+ StatsMapped runs on a single free-tier instance. A remote MCP endpoint hosted there would let an
83
+ agent's own multi-area query pattern (calling the same tool once per area, in a loop) reproduce
84
+ exactly the load pattern that has already caused timeouts on that instance under a large
85
+ geography fan-out. Running over stdio means every call goes through your own network connection
86
+ to the same public HTTPS API this package's tools call directly, with no shared bottleneck --
87
+ each user's own machine makes the HTTP calls, so N users' traffic is naturally spread across N
88
+ source IPs, not funnelled through one.
89
+
90
+ `server.py` also supports a real hosted `streamable-http` mode (`MCP_TRANSPORT=streamable-http`)
91
+ for a deployment that accepts that tradeoff -- StatsMapped's public API is itself rate-limited
92
+ per source IP (600 requests/hour), but a hosted MCP endpoint proxies every remote user's calls
93
+ through ONE shared egress IP, so all remote users of a hosted endpoint would share that one
94
+ bucket rather than each getting their own. A StatsMapped-hosted endpoint is live at `https://mcp.statsmapped.com`
95
+ (Streamable HTTP) -- confirmed responding correctly, no separate install needed for a client
96
+ that speaks Streamable HTTP directly.
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ pip install -e .
102
+ python tests/test_client.py
103
+ ```
104
+
105
+ The test suite runs against the real live API (`https://statsmapped.com` by default, or
106
+ `STATSMAPPED_MCP_BASE_URL` if set) — read-only GETs only, nothing here writes any data or needs
107
+ a key.
108
+
109
+ ## Releasing
110
+
111
+ Publishing to PyPI happens automatically via `.github/workflows/publish.yml` on creating a
112
+ GitHub Release — no API token is stored anywhere. It uses
113
+ [PyPI's Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC): PyPI is told,
114
+ once, to trust this exact repo + workflow file + GitHub environment (`pypi`) combination, via
115
+ PyPI's own "Publishing" settings page under this project. To release: bump `version` in
116
+ `pyproject.toml`, commit, then draft a GitHub Release with a matching tag (e.g. `v0.2.0`).
117
+
118
+ ## Licence
119
+
120
+ MIT for this package. The underlying data keeps each publisher's own licence — see
121
+ [statsmapped.com/ireland/sources](https://statsmapped.com/ireland/sources) for Ireland's own
122
+ publisher/licence detail before reusing any figure outside of querying it through an agent (a UK
123
+ equivalent page doesn't exist yet — check each UK tool response's own `caveats`/`sources` fields
124
+ in the meantime).
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "statsmapped-mcp"
7
+ version = "0.1.0"
8
+ description = "MCP server exposing StatsMapped's public data API for Ireland and the UK (housing, crime, health, economy, social welfare, by county/local authority) as tools for AI agents."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "StatsMapped", email = "feedback@statsmapped.com" }]
13
+ keywords = ["mcp", "model-context-protocol", "ireland", "united-kingdom", "uk", "open-data", "statistics"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Software Development :: Libraries :: Python Modules",
20
+ ]
21
+ dependencies = [
22
+ # server.py imports mcp.server.mcpserver.MCPServer, which only exists in mcp 2.x
23
+ # (FastMCP was renamed to MCPServer there) -- 1.2.0 would install and fail at import time.
24
+ # Upper-bounded at <3: this dependency has already broken its own public API once
25
+ # (the FastMCP -> MCPServer rename between majors), and an unbounded pin means a
26
+ # future mcp 3.x silently breaks every `pip install statsmapped-mcp` done after it
27
+ # lands, on a package with no maintainer actively watching for that (api-product-
28
+ # consultant, 2026-09-19).
29
+ "mcp>=2.0.0,<3",
30
+ "httpx>=0.27",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://statsmapped.com"
35
+ "API docs" = "https://statsmapped.com/openapi.json"
36
+ Source = "https://github.com/ActiveGuy/statsmapped-mcp"
37
+
38
+ [project.scripts]
39
+ statsmapped-mcp = "statsmapped_mcp.server:main"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/statsmapped_mcp"]
@@ -0,0 +1,3 @@
1
+ """statsmapped-mcp: an MCP server exposing StatsMapped's public API as tools."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,348 @@
1
+ """
2
+ Pure HTTP-calling and response-shaping logic for the 7 MCP tools, kept separate
3
+ from `server.py`'s MCP/decorator wiring so it can be unit-tested as plain
4
+ functions (no MCP runtime needed) -- same "logic separate from framework
5
+ plumbing" split the parent StatsMapped project itself uses throughout
6
+ `databeat/api.py`.
7
+
8
+ Talks ONLY to StatsMapped's already-public, unauthenticated HTTPS API
9
+ (https://statsmapped.com/api/v1/*, documented at /openapi.json) -- this package
10
+ has no access to and no dependency on the StatsMapped repository's own database,
11
+ source code, or internals. It is a thin client an AI agent runs on the user's
12
+ own machine (stdio transport), calling the same API the site's own front end
13
+ does.
14
+
15
+ BASE_URL is overridable via the STATSMAPPED_MCP_BASE_URL environment variable --
16
+ mainly so this package's own tests can point at a local/staging instance rather
17
+ than hammering production on every test run, and so a user who ever needs to
18
+ point this at a different deployment (a future country, a self-hosted mirror)
19
+ can do so without a code change.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ from importlib.metadata import PackageNotFoundError, version
26
+ from typing import Any
27
+
28
+ import httpx
29
+
30
+ BASE_URL = os.environ.get("STATSMAPPED_MCP_BASE_URL", "https://statsmapped.com")
31
+ TIMEOUT_SECONDS = 20.0
32
+ # Read from the installed package's own metadata rather than a hardcoded literal --
33
+ # a hardcoded string duplicating pyproject.toml's version drifts on the first release
34
+ # after this file stops being touched (api-product-consultant, 2026-09-19). Falls back
35
+ # to "dev" for an editable/uninstalled checkout rather than raising.
36
+ try:
37
+ _PKG_VERSION = version("statsmapped-mcp")
38
+ except PackageNotFoundError:
39
+ _PKG_VERSION = "dev"
40
+ USER_AGENT = f"statsmapped-mcp/{_PKG_VERSION} (+https://statsmapped.com)"
41
+
42
+ # 2026-08-30 (developer question -> api-product-consultant): nothing in this package
43
+ # told the calling LLM to credit StatsMapped when it uses this data in an answer.
44
+ # Plain text, not a markdown link -- the consultant's steer was that a link buried in
45
+ # a data field tends to get dropped in an LLM's own synthesis step, where a server-
46
+ # level instruction (this string, surfaced to the MODEL, not just the human reading
47
+ # raw JSON) is the mechanism actually likely to survive into a generated answer.
48
+ # One top-level field per response, not per-record -- today's own payload-size audit
49
+ # already flagged large unfiltered responses, and repeating this string per row in
50
+ # list_area_datasets' `available`/`not_comparable`/`national` arrays would add
51
+ # needless bytes for a fact that's true of the whole response, not any one row.
52
+ ATTRIBUTION = "Data from StatsMapped (https://statsmapped.com) -- cite as the source."
53
+
54
+
55
+ class StatsMappedAPIError(RuntimeError):
56
+ """Raised on any non-2xx response, with the API's own error detail if it sent
57
+ one -- so an agent calling a tool gets a legible reason (e.g. "unknown
58
+ geography 'county:atlantis'") rather than a bare stack trace or HTTP status.
59
+
60
+ `status_code` is carried as a real attribute (not just embedded in the
61
+ message string) so a caller that needs to distinguish "not found" from
62
+ "server error" -- rank_areas()'s own level-mismatch handling below is the
63
+ first real consumer -- can check `exc.status_code == 404` rather than
64
+ parsing this exception's own message text, which is meant for a human to
65
+ read, not a program to pattern-match.
66
+ """
67
+
68
+ def __init__(self, message: str, status_code: int | None = None) -> None:
69
+ super().__init__(message)
70
+ self.status_code = status_code
71
+
72
+
73
+ # todo:5858 (multi-agent-clearance triage, 2026-09-03): every call in this file used
74
+ # to hit the bare /api/v1/* path, which only ever reaches whichever country the
75
+ # SERVER happens to be booted as (Ireland, in production) -- there was no way for a
76
+ # tool caller to ask for UK data at all, not just a wording gap. `country` is now a
77
+ # real parameter on every tool/client function below, always resolved to the
78
+ # EXPLICIT /{country}/api/v1/* form -- confirmed on the parent site (StatsMapped's
79
+ # own /api landing page, 2026-09-03) that the bare and prefixed forms return
80
+ # byte-identical data for the same country, so defaulting to "ireland" here changes
81
+ # nothing for any existing caller that doesn't pass country.
82
+ VALID_COUNTRIES = {"ireland", "united-kingdom"}
83
+
84
+
85
+ def _api_path(suffix: str, country: str) -> str:
86
+ if country not in VALID_COUNTRIES:
87
+ raise StatsMappedAPIError(
88
+ f"country must be one of {sorted(VALID_COUNTRIES)}, got {country!r}")
89
+ return f"/{country}/api/v1{suffix}"
90
+
91
+
92
+ def _get(path: str, params: dict[str, Any] | None = None) -> Any:
93
+ url = f"{BASE_URL}{path}"
94
+ with httpx.Client(timeout=TIMEOUT_SECONDS, headers={"User-Agent": USER_AGENT}) as client:
95
+ resp = client.get(url, params=params or {})
96
+ if resp.status_code >= 400:
97
+ detail = None
98
+ try:
99
+ body = resp.json()
100
+ detail = ((body.get("error") or {}).get("message")
101
+ or body.get("detail") or body)
102
+ except ValueError:
103
+ detail = resp.text[:300]
104
+ raise StatsMappedAPIError(
105
+ f"{resp.status_code} from {path}: {detail}", status_code=resp.status_code)
106
+ return resp.json()
107
+
108
+
109
+ def list_datasets(country: str = "ireland") -> list[dict[str, Any]]:
110
+ """Every stat StatsMapped tracks for one country -- key, label, which boundary
111
+ levels it can be shown at. Ireland and the UK track different stats (18 vs.
112
+ 3 today), so this is genuinely per-country, not a shared catalogue filtered
113
+ after the fact. Source: GET /{country}/api/v1/stats.
114
+ """
115
+ return _get(_api_path("/stats", country))
116
+
117
+
118
+ def list_areas(level: str = "county", country: str = "ireland") -> list[dict[str, Any]]:
119
+ """Every geography StatsMapped knows about at one boundary level, for one
120
+ country (default: Ireland's "county" -- the 26 counties; the UK's own
121
+ primary level is "lad", its local authority districts). `with_boundary` is
122
+ never requested: an agent needs the id/name to look datasets up by, not
123
+ GeoJSON geometry. Source: GET /{country}/api/v1/geographies?level={level}.
124
+ """
125
+ rows = _get(_api_path("/geographies", country), params={"level": level})
126
+ # Boundary/centroid are always absent already (with_boundary defaults to
127
+ # false), but stripped explicitly in case that default ever changes --
128
+ # this tool's whole point is staying small, not "small today".
129
+ return [{k: v for k, v in row.items() if k not in ("boundary", "centroid")}
130
+ for row in rows]
131
+
132
+
133
+ def _project_caveats(caveats: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
134
+ """Label + severity only, per this tool's own spec -- a caveat `body` can run
135
+ to several sentences and this tool may return dozens of series for one area;
136
+ an agent deciding WHETHER to look closer needs to know a caveat exists and
137
+ how serious it is, not read the full text for every one up front. The full
138
+ text is one `get_dataset_for_area` call away for whichever series turns out
139
+ to matter.
140
+ """
141
+ return [{"label": c.get("label"), "severity": c.get("severity")}
142
+ for c in (caveats or [])]
143
+
144
+
145
+ def _project_series_entry(entry: dict[str, Any]) -> dict[str, Any]:
146
+ projected = {
147
+ "series_key": entry.get("series_key"),
148
+ "display_name": entry.get("display_name"),
149
+ "stat_key": entry.get("stat_key"),
150
+ "unit": entry.get("unit"),
151
+ "latest_period": entry.get("latest_period"),
152
+ "latest_value": entry.get("latest_value"),
153
+ "year_on_year_pct": entry.get("year_on_year_pct"),
154
+ "caveats": _project_caveats(entry.get("caveats")),
155
+ }
156
+ if entry.get("geo_match") and entry["geo_match"] != "exact":
157
+ # geo_match()'s own verdict -- an agent citing this figure for THIS area
158
+ # needs to know it's actually published for a wider region, the same
159
+ # disclosure the site's own pages carry (geographic_caveat()).
160
+ projected["geo_match"] = entry["geo_match"]
161
+ projected["geo_name"] = entry.get("geo_name")
162
+ return projected
163
+
164
+
165
+ def list_area_datasets(area_id: str, country: str = "ireland") -> dict[str, Any]:
166
+ """Every dataset available for one area, projected down to what an agent
167
+ needs to decide which ones matter: name, latest figure, year-on-year change,
168
+ and caveat labels/severity only (not full bodies -- see `_project_caveats`).
169
+ Full detail for any ONE dataset is `get_dataset_for_area`, one call away.
170
+ `country` must match whichever country `area_id` actually came from
171
+ (`list_areas`' own `country` argument) -- an Irish area_id against
172
+ country="united-kingdom" simply 404s, the API's own "unknown geography"
173
+ behaviour, not a silently wrong answer.
174
+
175
+ Source: GET /{country}/api/v1/geographies/{area_id}/series. Splits
176
+ `available` from `not_comparable`/`national` exactly as the API itself does
177
+ (architecture-design.md 4.8's own "a frontend cannot accidentally render
178
+ them by ignoring a boolean" reasoning applies here too -- an agent should
179
+ not accidentally cite a not-directly-comparable figure as this area's own).
180
+ """
181
+ data = _get(_api_path(f"/geographies/{area_id}/series", country))
182
+ return {
183
+ "attribution": ATTRIBUTION,
184
+ "geography": data.get("geography"),
185
+ "available": [_project_series_entry(e) for e in data.get("available", [])],
186
+ "not_comparable": [_project_series_entry(e)
187
+ for e in data.get("not_comparable", [])],
188
+ "national": [_project_series_entry(e) for e in data.get("national", [])],
189
+ }
190
+
191
+
192
+ def get_dataset_for_area(area_id: str, dataset: str,
193
+ history_months: int = 0, country: str = "ireland") -> dict[str, Any]:
194
+ """Full detail for ONE dataset in ONE area -- the drill-down tool, unlike
195
+ `list_area_datasets`'s projection: full caveat bodies, full facet history if
196
+ `history_months` is set. `dataset` is either a series `group_key` or a plain
197
+ `series_key`, from `list_area_datasets`' own `series_key` field. `country`
198
+ must match `area_id`'s own country, same as `list_area_datasets`.
199
+
200
+ `history_months` (0 = full history) is passed straight through -- as of
201
+ 2026-08-30 the API converts it to this dataset's own PERIOD count
202
+ (annual/quarterly/monthly) rather than a raw row count, so `history_months=24`
203
+ correctly means "the last 2 years" on an annual series, not 24 years.
204
+
205
+ Source: GET /{country}/api/v1/geographies/{area_id}/datasets/{dataset}.
206
+ """
207
+ params = {"history_months": history_months} if history_months else None
208
+ result = _get(_api_path(f"/geographies/{area_id}/datasets/{dataset}", country),
209
+ params=params)
210
+ result["attribution"] = ATTRIBUTION
211
+ return result
212
+
213
+
214
+ def list_comparisons(country: str = "ireland") -> list[dict[str, Any]]:
215
+ """Every registered cross-dataset comparison pair for one country -- e.g.
216
+ "median sale price vs new dwelling completions per 1,000 residents". A small,
217
+ hand-curated set (a handful of pairs per country), not an arbitrary-pair
218
+ engine -- pass one of the `pair_key` values returned here to `get_comparison`
219
+ for the real correlation and per-area scatter data.
220
+
221
+ CONTRACT-PARITY item 4 remainder (TODO.md "CONTRACT PARITY (not primacy
222
+ inversion)", developer-approved): this package had rank_areas but no
223
+ equivalent for the comparison layer the parent site's own chat tool of the
224
+ same name already exposes -- an agent asking "how does X relate to Y" had no
225
+ way to discover a registered pair existed at all. Source: GET
226
+ /{country}/api/v1/comparisons, added alongside this tool (item 2).
227
+ """
228
+ return _get(_api_path("/comparisons", country))
229
+
230
+
231
+ def get_comparison(pair_key: str, country: str = "ireland") -> dict[str, Any]:
232
+ """Full detail for one registered comparison pair: each axis's label/unit/
233
+ publisher, the correlation stats (r, rho, a leave-one-out sensitivity range),
234
+ and caveats -- everything except the raw per-area scatter points, which this
235
+ tool omits (they're the one part a text-answering agent has no use for, and
236
+ the source route can return dozens of them). `pair_key` comes from
237
+ `list_comparisons(country=...)`. Source: GET /{country}/api/v1/comparisons/
238
+ {pair_key}.
239
+ """
240
+ result = _get(_api_path(f"/comparisons/{pair_key}", country))
241
+ result.pop("points", None)
242
+ result["attribution"] = ATTRIBUTION
243
+ return result
244
+
245
+
246
+ def check_comparability(stat_key_a: str, stat_key_b: str,
247
+ country: str = "ireland") -> dict[str, Any]:
248
+ """Does StatsMapped have a registered, hand-vetted comparison for these two
249
+ stats? Registry-backed only -- this never computes a fresh correlation for an
250
+ arbitrary pair, and says so plainly (`reason`) whichever way it answers.
251
+ `comparable: false` is a normal, expected result for most stat_key pairs (the
252
+ registry is small and hand-curated, a handful of pairs per country), not an
253
+ error -- refusing tells you as much as confirming does: don't compute or
254
+ imply a relationship between two stats StatsMapped hasn't vetted, even if the
255
+ figures themselves are individually real. Source: GET /{country}/api/v1/
256
+ comparisons/check?stat_key_a=...&stat_key_b=....
257
+ """
258
+ return _get(_api_path("/comparisons/check", country),
259
+ params={"stat_key_a": stat_key_a, "stat_key_b": stat_key_b})
260
+
261
+
262
+ def explain_metric(stat_key: str, country: str = "ireland") -> dict[str, Any]:
263
+ """Definition, methodology and standing caveats for ONE stat -- never a
264
+ current figure (`get_dataset_for_area`/`rank_areas` already answer "what is
265
+ this right now"; this answers "what does this even mean"). Useful before
266
+ citing a figure at all, or when a reader's own question is about the metric
267
+ itself ("how is the claimant count actually defined"), not a specific area's
268
+ value. `stat_key` comes from `list_datasets(country=...)`. Source: GET
269
+ /{country}/api/v1/stats/{stat_key}/explain.
270
+ """
271
+ result = _get(_api_path(f"/stats/{stat_key}/explain", country))
272
+ result["attribution"] = ATTRIBUTION
273
+ return result
274
+
275
+
276
+ def rank_areas(stat_key: str, level: str | None = None,
277
+ country: str = "ireland") -> list[dict[str, Any]]:
278
+ """Every area at one boundary level, ranked by its latest figure for one
279
+ stat -- "which counties have the highest median sale price", "which local
280
+ authorities award the most single-bid contracts". `stat_key` must be one
281
+ this `country` actually tracks (see `list_datasets(country=...)`) -- Ireland
282
+ and the UK have different catalogues. `level` omitted uses the ranking's own
283
+ default level for this stat; pass one of `list_datasets`' own
284
+ `compatible_levels` to see a different registered level.
285
+
286
+ CONTRACT-PARITY FIX (TODO.md "CONTRACT PARITY (not primacy inversion)",
287
+ developer-approved): this used to hand-roll its own join (raw
288
+ `latest_value` sort over `/series`, filtered to `/geographies?level=`) --
289
+ no rate normalisation and no caveats field at all, confirmed live to
290
+ mis-rank every stat in the parent site's own RANKING_NO_DENOMINATOR_STATS
291
+ (crime, live_register, homelessness, road_collisions, hospital_discharges,
292
+ ntpf_op, ntpf_ipdc) by raw population size rather than the honest per-1,000
293
+ rate the site's own /rankings pages and in-product chat both use. Now calls
294
+ GET /{country}/api/v1/rankings/{slug} directly -- the SAME computation
295
+ (_rankings_data(), api.py), so this tool can never rank an area differently
296
+ from what a reader would see citing the matching StatsMapped page.
297
+
298
+ `stat_key` -> ranking slug is resolved via `list_datasets(country=...)`'s
299
+ own `ranking_link` field (the SAME field the site's own rail links and
300
+ dataset pages use to build a "See the full ranking" link) -- never a
301
+ second, hand-maintained translation table that could drift from it. A stat
302
+ with no `ranking_link` (no ranking published at all) raises rather than
303
+ silently returning an empty list, since that is a genuinely different case
304
+ from "this stat has no ranking at the LEVEL you asked for" just below.
305
+
306
+ A `level` this ranking doesn't have registered returns an empty list, not
307
+ an error -- the ranking slug is already confirmed real by this point (the
308
+ `ranking_link` lookup above), so a 404 here can only mean the level
309
+ mismatch case (`_RankingsNotFound`'s own "has no {level} level" case,
310
+ api.py) -- preserves this tool's long-standing contract (test_client.py):
311
+ a stat/level mismatch is a legible empty result, not a crash.
312
+ """
313
+ stats = list_datasets(country=country)
314
+ stat = next((s for s in stats if s.get("key") == stat_key), None)
315
+ if stat is None:
316
+ raise StatsMappedAPIError(f"{stat_key!r} is not a stat {country} tracks.")
317
+ ranking_link = stat.get("ranking_link")
318
+ if not ranking_link:
319
+ raise StatsMappedAPIError(
320
+ f"{stat_key!r} has no ranking published on StatsMapped for {country}.")
321
+ try:
322
+ data = _get(_api_path(f"/rankings/{ranking_link['slug']}", country),
323
+ params={"level": level} if level else None)
324
+ except StatsMappedAPIError as exc:
325
+ if exc.status_code == 404:
326
+ return []
327
+ raise
328
+ return [
329
+ {
330
+ "rank": i + 1,
331
+ "geography_id": row.get("geo_id"),
332
+ "display_name": row.get("name"),
333
+ "latest_value": row.get("value"),
334
+ "latest_period": data.get("latest_period"),
335
+ "year_on_year_pct": row.get("yoy"),
336
+ "unit": data.get("unit"),
337
+ # New in this fix, not in the old hand-rolled version: the honest
338
+ # per-1,000 rate (when this ranking is rate-ranked -- see
339
+ # data["rate_ranked"]/RankingsResponse.rate_ranked, api.py) and the
340
+ # sample_size a small-base caveat depends on, plus the caveats
341
+ # themselves -- exactly the two things the old version had no way
342
+ # to surface at all.
343
+ "rate_per_1000": row.get("rate_per_1000"),
344
+ "sample_size": row.get("sample_size"),
345
+ "caveats": _project_caveats(data.get("caveats")),
346
+ }
347
+ for i, row in enumerate(data.get("rows", []))
348
+ ]
@@ -0,0 +1,208 @@
1
+ """
2
+ MCP server wiring for statsmapped-mcp's 7 tools. All the actual HTTP-calling/
3
+ response-shaping logic lives in `client.py`, kept separate and independently
4
+ testable -- this module is just the MCP registration layer.
5
+
6
+ Run directly (`python -m statsmapped_mcp.server`) or via the `statsmapped-mcp`
7
+ console script this package installs. Stdio by default, unchanged for local/
8
+ Desktop use -- set MCP_TRANSPORT=streamable-http (TODO.md PRIORITY 9, live
9
+ PageSpeed report, 2026-09-06) to run a real hosted HTTP endpoint instead, e.g.
10
+ for a Render deployment. See this package's own README for the load-sharing
11
+ risk a hosted endpoint reintroduces (stdio's real advantage was never auth --
12
+ `/api/v1` is public, unauthenticated data either way -- it was that each
13
+ user's own machine makes the HTTP calls, so N users' cost is naturally spread
14
+ across N source IPs; a hosted endpoint collapses that to one shared egress IP
15
+ and one shared 600/hr rate-limit bucket, `rate_limit_public_api`, api.py).
16
+ Accepted as a v1 tradeoff, watched via `fail_open_count()`/`decisions` gate
17
+ rows rather than solved with per-session rate-limit keying up front.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ from typing import Any
24
+
25
+ from mcp.server.mcpserver import MCPServer
26
+ from mcp.server.transport_security import TransportSecuritySettings
27
+
28
+ from statsmapped_mcp import client
29
+
30
+ server = MCPServer(
31
+ name="statsmapped",
32
+ title="StatsMapped Public Data",
33
+ description=(
34
+ "Public data for Ireland and the UK, by county/local authority: housing, "
35
+ "crime, health, the economy and social welfare, from official publishers "
36
+ "(CSO, PSRA, Central Bank of Ireland, DHLGH, NTPF, the Office of "
37
+ "Government Procurement, EU Publications Office for Ireland; ONS/Land "
38
+ "Registry-sourced series for the UK), updated on each publisher's own "
39
+ "schedule. Every tool below takes a `country` argument ('ireland' or "
40
+ "'united-kingdom', default 'ireland') -- the two countries track "
41
+ "different datasets and geography levels, so call `list_datasets`/"
42
+ "`list_areas` for the country you actually want before assuming Irish "
43
+ "defaults apply. Every figure carries its own caveats -- where a "
44
+ "statistic is published for a wider area than requested, or where the "
45
+ "publisher itself flags it as unreliable, these tools say so. When you "
46
+ "use this data in an answer, cite StatsMapped (https://statsmapped.com) "
47
+ "as the source."
48
+ ),
49
+ )
50
+
51
+
52
+ @server.tool()
53
+ def list_datasets(country: str = "ireland") -> list[dict[str, Any]]:
54
+ """List every dataset (stat) StatsMapped tracks for one country ('ireland'
55
+ or 'united-kingdom'), with its key, human label, and which geography levels
56
+ it can be shown at. Ireland and the UK track genuinely different datasets --
57
+ call this for the right country before assuming a stat_key exists there.
58
+ Call this first to find the right `stat_key` for `rank_areas` -- for
59
+ `get_dataset_for_area`, use `list_area_datasets` instead, which returns
60
+ the `series_key` that call actually needs.
61
+ """
62
+ return client.list_datasets(country=country)
63
+
64
+
65
+ @server.tool()
66
+ def list_areas(level: str = "county", country: str = "ireland") -> list[dict[str, Any]]:
67
+ """List every geography at one boundary level, for one country ('ireland'
68
+ or 'united-kingdom'). `level` defaults to "county" (Ireland's 26 counties);
69
+ the UK's own primary level is "lad" (local authority districts), not
70
+ "county". Other levels exist per country (e.g. Ireland's "local_authority",
71
+ "garda_division") -- see a dataset's own `compatible_levels` from
72
+ `list_datasets` for which levels a given stat is actually published at.
73
+ Returns each area's `id` (used by `list_area_datasets`/`get_dataset_for_area`,
74
+ always paired with the SAME `country`) and `name`.
75
+ """
76
+ return client.list_areas(level=level, country=country)
77
+
78
+
79
+ @server.tool()
80
+ def list_area_datasets(area_id: str, country: str = "ireland") -> dict[str, Any]:
81
+ """List every dataset available for one area (e.g. "county:kerry" for
82
+ Ireland, "uk:lad:e09000033" for the UK), with its latest figure, year-on-
83
+ year change, and caveat labels only (not full caveat text -- call
84
+ `get_dataset_for_area` for the full detail on any one dataset that
85
+ matters). Area ids come from `list_areas` -- `country` must match whichever
86
+ country that call used, or this simply 404s ("unknown geography").
87
+ """
88
+ return client.list_area_datasets(area_id, country=country)
89
+
90
+
91
+ @server.tool()
92
+ def get_dataset_for_area(area_id: str, dataset: str,
93
+ history_months: int = 0,
94
+ country: str = "ireland") -> dict[str, Any]:
95
+ """Full detail for one dataset in one area: the latest figure, a written
96
+ summary, full caveat text, and (if `history_months` is set) recent history.
97
+ `dataset` is a `series_key` from `list_area_datasets`' own response.
98
+ `history_months` means actual months of history (0 = everything) -- e.g. 24
99
+ returns 2 years of an annual series, not 24 years. `country` must match
100
+ `area_id`'s own country.
101
+ """
102
+ return client.get_dataset_for_area(area_id, dataset, history_months=history_months,
103
+ country=country)
104
+
105
+
106
+ @server.tool()
107
+ def rank_areas(stat_key: str, level: str | None = None,
108
+ country: str = "ireland") -> list[dict[str, Any]]:
109
+ """Rank every area at one geography level by its latest figure for one stat,
110
+ for one country -- e.g. "which counties have the highest median sale price"
111
+ (country="ireland") or "which local authorities award the most single-bid
112
+ contracts" (country="united-kingdom"). `stat_key` comes from
113
+ `list_datasets(country=...)` for the SAME country -- Ireland and the UK
114
+ track different stats. `level` omitted uses this ranking's own default
115
+ level; pass one of that dataset's own `compatible_levels` for a different
116
+ one -- a level this ranking doesn't have registered returns an empty list
117
+ rather than an error.
118
+
119
+ Where the underlying stat has no honest per-area denominator (crime,
120
+ homelessness, live_register and similar -- StatsMapped's own
121
+ RANKING_NO_DENOMINATOR_STATS), each row's `rate_per_1000` is the real
122
+ figure to rank/compare by, not `latest_value`, which is a raw count
123
+ dominated by area population size. Always carry forward every entry in
124
+ `caveats` when using a row in an answer -- the same caveats StatsMapped's
125
+ own ranking pages and chat both attach to these figures.
126
+ """
127
+ return client.rank_areas(stat_key, level=level, country=country)
128
+
129
+
130
+ @server.tool()
131
+ def list_comparisons(country: str = "ireland") -> list[dict[str, Any]]:
132
+ """List every registered cross-dataset comparison pair for one country
133
+ ('ireland' or 'united-kingdom') -- e.g. "median sale price vs new dwelling
134
+ completions per 1,000 residents". A small, hand-curated set, not an
135
+ arbitrary-pair engine: pass one of the returned `pair_key` values to
136
+ `get_comparison` for the real correlation and axis detail.
137
+ """
138
+ return client.list_comparisons(country=country)
139
+
140
+
141
+ @server.tool()
142
+ def get_comparison(pair_key: str, country: str = "ireland") -> dict[str, Any]:
143
+ """Full detail for one registered comparison pair: each axis's label, unit
144
+ and publisher, the correlation stats (r, rho, and a leave-one-out
145
+ sensitivity range naming the single most influential area), and caveats.
146
+ `pair_key` comes from `list_comparisons(country=...)` for the SAME country.
147
+ """
148
+ return client.get_comparison(pair_key, country=country)
149
+
150
+
151
+ @server.tool()
152
+ def check_comparability(stat_key_a: str, stat_key_b: str,
153
+ country: str = "ireland") -> dict[str, Any]:
154
+ """Does StatsMapped have a registered, hand-vetted comparison between these
155
+ two stats ('ireland' or 'united-kingdom')? Registry-backed only -- never
156
+ computes a fresh correlation for an arbitrary pair. Both stat_keys come from
157
+ `list_datasets(country=...)` for the SAME country. A `comparable: false`
158
+ result is normal and expected for most pairs (the registry is small and
159
+ hand-curated) -- treat it as StatsMapped saying it has not vetted a
160
+ relationship between these two stats, not as an error to route around.
161
+ """
162
+ return client.check_comparability(stat_key_a, stat_key_b, country=country)
163
+
164
+
165
+ @server.tool()
166
+ def explain_metric(stat_key: str, country: str = "ireland") -> dict[str, Any]:
167
+ """Definition, methodology and standing caveats for ONE stat ('ireland' or
168
+ 'united-kingdom') -- never a current figure. Call this when the question is
169
+ about what a metric MEANS or how it's measured ("how is the claimant count
170
+ defined", "is this a mean or a median"), not about a specific area's value --
171
+ `get_dataset_for_area`/`rank_areas` already answer that. `stat_key` comes
172
+ from `list_datasets(country=...)` for the SAME country.
173
+ """
174
+ return client.explain_metric(stat_key, country=country)
175
+
176
+
177
+ def main() -> None:
178
+ transport = os.environ.get("MCP_TRANSPORT", "stdio")
179
+ if transport == "stdio":
180
+ server.run()
181
+ return
182
+ if transport not in ("sse", "streamable-http"):
183
+ raise ValueError(
184
+ f"MCP_TRANSPORT={transport!r} is not one of 'stdio'/'sse'/'streamable-http'")
185
+ # TODO.md PRIORITY 9: "0.0.0.0" (not "127.0.0.1"/"localhost") is required on
186
+ # Render, which assigns $PORT and expects binding on all interfaces --
187
+ # PREP DOC FACT 9: the SDK's own DNS-rebinding protection only auto-
188
+ # constructs a TransportSecuritySettings for a localhost host; for any other
189
+ # host it stays None (i.e. NO Host/Origin validation at all) unless one is
190
+ # passed explicitly, which is what MCP_ALLOWED_HOST is for below.
191
+ host = os.environ.get("MCP_HOST", "0.0.0.0")
192
+ port = int(os.environ.get("PORT", "8080"))
193
+ # Comma-separated, e.g. "mcp.statsmapped.com" -- the real public hostname is
194
+ # a developer decision (subdomain vs. a path on the existing service; see
195
+ # this item's own TODO.md entry), deliberately not guessed/hardcoded here.
196
+ # enable_dns_rebinding_protection defaults True (the library's own default,
197
+ # left on rather than overridden) -- with allowed_hosts/allowed_origins
198
+ # BOTH empty (this env var unset), that FAILS CLOSED (every request
199
+ # rejected) rather than failing open with no validation at all, so an
200
+ # incomplete deploy config is loud, not a silent security gap.
201
+ allowed = [h.strip() for h in os.environ.get("MCP_ALLOWED_HOST", "").split(",") if h.strip()]
202
+ transport_security = TransportSecuritySettings(
203
+ allowed_hosts=allowed, allowed_origins=allowed)
204
+ server.run(transport, host=host, port=port, transport_security=transport_security)
205
+
206
+
207
+ if __name__ == "__main__":
208
+ main()
@@ -0,0 +1,327 @@
1
+ """
2
+ Tests `client.py`'s 5 tool functions against the REAL live StatsMapped API
3
+ (https://statsmapped.com, or STATSMAPPED_MCP_BASE_URL if set) -- this package
4
+ has no database or source-code access of its own to test against, only the
5
+ public HTTPS API every other client also uses, so that is what "real" means
6
+ here. All calls are read-only GETs against an already-public, unauthenticated
7
+ API; nothing here writes anything or needs a key.
8
+
9
+ Run:
10
+ python -m pytest tests/test_client.py -v
11
+ or, dependency-free:
12
+ python tests/test_client.py
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
21
+
22
+ from statsmapped_mcp import client
23
+
24
+ failures: list[str] = []
25
+
26
+
27
+ def check(label: str, condition: bool, detail: str = "") -> None:
28
+ if not condition:
29
+ failures.append(f"{label}{': ' + detail if detail else ''}")
30
+
31
+
32
+ def main() -> int:
33
+ # --- list_datasets ------------------------------------------------------
34
+ datasets = client.list_datasets()
35
+ check("list_datasets returns a non-empty list", len(datasets) > 0)
36
+ check("every dataset has a key and label",
37
+ all("key" in d and "label" in d for d in datasets), str(datasets[:2]))
38
+ stat_keys = {d["key"] for d in datasets}
39
+ check("sale_price is a known stat", "sale_price" in stat_keys, str(stat_keys))
40
+
41
+ # --- list_areas ----------------------------------------------------------
42
+ counties = client.list_areas(level="county")
43
+ check("list_areas('county') returns all 26 counties", len(counties) == 26,
44
+ f"got {len(counties)}")
45
+ check("no county row carries boundary/centroid",
46
+ all("boundary" not in c and "centroid" not in c for c in counties))
47
+ kerry = next((c for c in counties if c["id"] == "county:kerry"), None)
48
+ check("county:kerry is present", kerry is not None, str(counties[:3]))
49
+ check("county:kerry is named Kerry", kerry and kerry.get("name") == "Kerry",
50
+ str(kerry))
51
+
52
+ # --- list_area_datasets ---------------------------------------------------
53
+ kerry_datasets = client.list_area_datasets("county:kerry")
54
+ check("list_area_datasets returns available/not_comparable/national",
55
+ {"available", "not_comparable", "national"} <= set(kerry_datasets),
56
+ str(list(kerry_datasets)))
57
+ check("list_area_datasets carries a top-level attribution field",
58
+ kerry_datasets.get("attribution") == client.ATTRIBUTION,
59
+ str(kerry_datasets.get("attribution")))
60
+ available = kerry_datasets["available"]
61
+ check("Kerry has at least one available dataset", len(available) > 0)
62
+ sale_price_entry = next(
63
+ (e for e in available if e.get("stat_key") == "sale_price"), None)
64
+ check("Kerry's median sale price is in the available list",
65
+ sale_price_entry is not None, str([e.get("stat_key") for e in available]))
66
+ if sale_price_entry:
67
+ check("projected entry carries no full caveat body",
68
+ all("body" not in c for c in sale_price_entry.get("caveats", [])),
69
+ str(sale_price_entry.get("caveats")))
70
+ check("projected entry keeps caveat label/severity",
71
+ all({"label", "severity"} <= set(c) for c in sale_price_entry.get("caveats", [])),
72
+ str(sale_price_entry.get("caveats")))
73
+ series_key_for_detail = sale_price_entry["series_key"]
74
+
75
+ # --- get_dataset_for_area --------------------------------------------------
76
+ if sale_price_entry:
77
+ detail = client.get_dataset_for_area("county:kerry", series_key_for_detail)
78
+ check("get_dataset_for_area returns the dataset's own name",
79
+ "name" in detail.get("dataset", {}), str(detail)[:300])
80
+ check("get_dataset_for_area carries full caveat bodies (unprojected)",
81
+ any(c.get("body") for c in detail.get("caveats", [])),
82
+ str(detail.get("caveats"))[:300])
83
+ check("get_dataset_for_area carries a top-level attribution field",
84
+ detail.get("attribution") == client.ATTRIBUTION,
85
+ str(detail.get("attribution")))
86
+
87
+ detail_2yr = client.get_dataset_for_area(
88
+ "county:kerry", series_key_for_detail, history_months=24)
89
+ facets = detail_2yr.get("dataset", {}).get("facets", [])
90
+ if facets:
91
+ history = facets[0].get("history", [])
92
+ # median sale price is quarterly -- 24 months should mean ~8 quarters,
93
+ # never the raw-row-count bug (24 points) this project fixed 2026-08-30.
94
+ check("history_months=24 does not return 24 raw rows on non-monthly data",
95
+ len(history) <= 12, f"got {len(history)} points: {history}")
96
+
97
+ # --- rank_areas ------------------------------------------------------------
98
+ ranking = client.rank_areas("sale_price", level="county")
99
+ check("rank_areas('sale_price') returns rankings", len(ranking) > 0)
100
+ check("ranking is sorted descending by latest_value",
101
+ all(ranking[i]["latest_value"] >= ranking[i + 1]["latest_value"]
102
+ for i in range(len(ranking) - 1)),
103
+ str([r["latest_value"] for r in ranking]))
104
+ check("ranks are 1-indexed and sequential",
105
+ [r["rank"] for r in ranking] == list(range(1, len(ranking) + 1)),
106
+ str([r["rank"] for r in ranking][:5]))
107
+
108
+ # A stat published at a level this call didn't ask for should come back
109
+ # empty, not wrong or crashing -- e.g. eTenders is local_authority-level,
110
+ # not county.
111
+ empty_ranking = client.rank_areas("etenders", level="garda_division")
112
+ check("a stat/level mismatch returns an empty list, not an error",
113
+ empty_ranking == [], str(empty_ranking))
114
+
115
+ # A stat with no ranking published at all is a genuinely different case
116
+ # from a level mismatch above -- raises, rather than a silent empty list
117
+ # that could be mistaken for "no data at this level".
118
+ try:
119
+ client.rank_areas("not_a_real_stat_key")
120
+ check("an unknown/unranked stat_key raises", False,
121
+ "expected StatsMappedAPIError, got a normal return")
122
+ except client.StatsMappedAPIError:
123
+ pass
124
+
125
+ # --- rank_areas: CONTRACT-PARITY FIX regression check -----------------------
126
+ # TODO.md "CONTRACT PARITY (not primacy inversion)": this tool used to hand-roll
127
+ # a raw latest_value sort with no rate field and no caveats at all -- confirmed
128
+ # live to mis-rank every RANKING_NO_DENOMINATOR_STATS member (live_register
129
+ # included) by population size. UK's own live_register IS rate-ranked (Ireland's
130
+ # own is not today -- real population-series coverage gap, not this tool's
131
+ # concern), so it is real, live proof the fix actually reaches production data,
132
+ # not just that the new code path executes.
133
+ uk_live_register = client.rank_areas("live_register", country="united-kingdom")
134
+ check("UK live_register ranking is non-empty", len(uk_live_register) > 0,
135
+ str(uk_live_register[:3]))
136
+ check("UK live_register rows carry a real rate_per_1000 (the fix's whole point)",
137
+ uk_live_register and all(r.get("rate_per_1000") is not None
138
+ for r in uk_live_register),
139
+ str(uk_live_register[:3]))
140
+ check("UK live_register is ranked by rate_per_1000 descending, not raw latest_value",
141
+ all(uk_live_register[i]["rate_per_1000"] >= uk_live_register[i + 1]["rate_per_1000"]
142
+ for i in range(len(uk_live_register) - 1)),
143
+ str([r["rate_per_1000"] for r in uk_live_register[:5]]))
144
+ check("UK live_register rows carry real caveats (also absent from the old version)",
145
+ uk_live_register and all(len(r.get("caveats") or []) > 0
146
+ for r in uk_live_register),
147
+ str(uk_live_register[0].get("caveats") if uk_live_register else None))
148
+
149
+ # --- list_comparisons / get_comparison --------------------------------------
150
+ # CONTRACT-PARITY item 4 remainder (TODO.md "CONTRACT PARITY (not primacy
151
+ # inversion)"): this package had no access to the comparison layer the parent
152
+ # site's own in-product chat already exposes -- these two tools close that gap
153
+ # once item 2's GET /api/v1/comparisons list route existed for them to call.
154
+ comparisons = client.list_comparisons()
155
+ check("list_comparisons returns a non-empty list", len(comparisons) > 0)
156
+ check("every comparison carries pair_key/slug/label",
157
+ all({"pair_key", "slug", "label"} <= set(c) for c in comparisons),
158
+ str(comparisons[:2]))
159
+ first_pair_key = comparisons[0]["pair_key"]
160
+
161
+ comparison_detail = client.get_comparison(first_pair_key)
162
+ check("get_comparison returns x/y axes and correlation stats",
163
+ {"x", "y", "stats", "caveats"} <= set(comparison_detail),
164
+ str(list(comparison_detail)))
165
+ check("get_comparison strips the raw per-area scatter points",
166
+ "points" not in comparison_detail, str(list(comparison_detail)))
167
+ check("get_comparison carries a top-level attribution field",
168
+ comparison_detail.get("attribution") == client.ATTRIBUTION,
169
+ str(comparison_detail.get("attribution")))
170
+ check("get_comparison's x/y axes each carry a label",
171
+ comparison_detail["x"].get("label") and comparison_detail["y"].get("label"),
172
+ str((comparison_detail.get("x"), comparison_detail.get("y"))))
173
+
174
+ try:
175
+ client.get_comparison("not_a_real_pair_key")
176
+ check("an unknown pair_key raises", False,
177
+ "expected StatsMappedAPIError, got a normal return")
178
+ except client.StatsMappedAPIError:
179
+ pass
180
+
181
+ uk_comparisons = client.list_comparisons(country="united-kingdom")
182
+ check("UK's comparison catalogue is non-empty and distinct from Ireland's",
183
+ len(uk_comparisons) > 0
184
+ and {c["pair_key"] for c in uk_comparisons}
185
+ != {c["pair_key"] for c in comparisons},
186
+ f"uk={uk_comparisons} ie={comparisons}")
187
+ uk_comparison_detail = client.get_comparison(
188
+ uk_comparisons[0]["pair_key"], country="united-kingdom")
189
+ check("get_comparison(country='united-kingdom') returns real UK axis data",
190
+ uk_comparison_detail.get("x", {}).get("label")
191
+ and uk_comparison_detail.get("y", {}).get("label"),
192
+ str(uk_comparison_detail))
193
+
194
+ # --- check_comparability / explain_metric -------------------------------
195
+ # TODO.md, MCP credibility pass, 2026-09-14: registry-backed only, both --
196
+ # neither computes a fresh correlation or reads a current figure.
197
+ x_key = comparison_detail["x"]["series_key"]
198
+ y_key = comparison_detail["y"]["series_key"]
199
+ real_pair = client.check_comparability(x_key, y_key)
200
+ check("check_comparability confirms a real registered pair",
201
+ real_pair.get("comparable") is True
202
+ and real_pair.get("pair_key") == first_pair_key,
203
+ str(real_pair))
204
+ check("a confirmed pair still states a reason",
205
+ bool(real_pair.get("reason")), str(real_pair))
206
+
207
+ no_pair = client.check_comparability("sale_price", "definitely_not_a_real_stat_key")
208
+ check("check_comparability refuses an unregistered pair rather than erroring",
209
+ no_pair.get("comparable") is False and no_pair.get("pair_key") is None,
210
+ str(no_pair))
211
+ check("a refusal states WHY, not just false",
212
+ bool(no_pair.get("reason")), str(no_pair))
213
+
214
+ explanation = client.explain_metric("sale_price")
215
+ check("explain_metric returns the stat's own label/unit/periodicity",
216
+ explanation.get("stat_key") == "sale_price" and explanation.get("label")
217
+ and explanation.get("unit"),
218
+ str(explanation))
219
+ check("explain_metric never returns a current figure",
220
+ "latest_value" not in explanation and "value" not in explanation,
221
+ str(list(explanation)))
222
+ check("explain_metric carries a top-level attribution field",
223
+ explanation.get("attribution") == client.ATTRIBUTION,
224
+ str(explanation.get("attribution")))
225
+
226
+ try:
227
+ client.explain_metric("definitely_not_a_real_stat_key")
228
+ check("an unknown stat_key raises", False,
229
+ "expected StatsMappedAPIError, got a normal return")
230
+ except client.StatsMappedAPIError:
231
+ pass
232
+
233
+ # --- country parameter (todo:5858) ------------------------------------
234
+ # Every function defaults to "ireland" -- confirm that default is unchanged
235
+ # (already exercised by every call above with no explicit country), then
236
+ # confirm country="united-kingdom" reaches genuinely different, real UK
237
+ # data rather than reusing Ireland's catalogue or 404ing.
238
+ uk_datasets = client.list_datasets(country="united-kingdom")
239
+ uk_stat_keys = {d["key"] for d in uk_datasets}
240
+ check("UK's dataset catalogue is non-empty and distinct from Ireland's",
241
+ len(uk_stat_keys) > 0 and uk_stat_keys != stat_keys,
242
+ f"uk={uk_stat_keys} ie={stat_keys}")
243
+ # TODO.md:ed5febde, 2026-09-06: "population" used to be UK-only and was this
244
+ # check's own example -- stale the moment Ireland got a real population stat
245
+ # too (seed_population.py, the homelessness-per-capita-ranking build, same
246
+ # session). road_collisions is still genuinely UK-only (confirmed against
247
+ # the live catalogues printed on failure, same as this check always has).
248
+ check("road_collisions (UK-only stat) is known to the UK catalogue, not Ireland's",
249
+ "road_collisions" in uk_stat_keys and "road_collisions" not in stat_keys,
250
+ f"uk={uk_stat_keys} ie={stat_keys}")
251
+
252
+ lads = client.list_areas(level="lad", country="united-kingdom")
253
+ check("list_areas('lad', 'united-kingdom') returns UK local authorities",
254
+ len(lads) > 0, f"got {len(lads)}")
255
+ aberdeen = next((a for a in lads if a["id"] == "uk:lad:s12000033"), None)
256
+ check("Aberdeen City is present in the UK LAD list", aberdeen is not None,
257
+ str(lads[:3]))
258
+ check("Aberdeen City is named correctly",
259
+ aberdeen and aberdeen.get("name") == "Aberdeen City", str(aberdeen))
260
+
261
+ if aberdeen:
262
+ aberdeen_datasets = client.list_area_datasets(
263
+ "uk:lad:s12000033", country="united-kingdom")
264
+ uk_available = aberdeen_datasets.get("available", [])
265
+ check("Aberdeen City has at least one available UK dataset",
266
+ len(uk_available) > 0, str(aberdeen_datasets))
267
+ claimant_entry = next(
268
+ (e for e in uk_available if e.get("stat_key") == "live_register"), None)
269
+ check("Aberdeen City's claimant count is in the available list",
270
+ claimant_entry is not None,
271
+ str([e.get("stat_key") for e in uk_available]))
272
+ if claimant_entry:
273
+ uk_detail = client.get_dataset_for_area(
274
+ "uk:lad:s12000033", claimant_entry["series_key"],
275
+ country="united-kingdom")
276
+ check("get_dataset_for_area(country='united-kingdom') returns the "
277
+ "dataset's own name",
278
+ "name" in uk_detail.get("dataset", {}), str(uk_detail)[:300])
279
+
280
+ uk_ranking = client.rank_areas("rppi", level="lad", country="united-kingdom")
281
+ check("rank_areas(country='united-kingdom') returns UK rankings",
282
+ len(uk_ranking) > 0, str(uk_ranking[:3]))
283
+
284
+ # An Irish area_id against the UK's own API path should 404 as "unknown
285
+ # geography", not silently return something or crash -- the two catalogues
286
+ # are genuinely separate, not a shared one filtered after the fact.
287
+ try:
288
+ client.list_area_datasets("county:kerry", country="united-kingdom")
289
+ check("an Irish area_id under country='united-kingdom' raises", False,
290
+ "expected StatsMappedAPIError, got a normal return")
291
+ except client.StatsMappedAPIError:
292
+ pass
293
+
294
+ try:
295
+ client.list_datasets(country="atlantis")
296
+ check("an invalid country raises before any HTTP call is made", False,
297
+ "expected StatsMappedAPIError, got a normal return")
298
+ except client.StatsMappedAPIError:
299
+ pass
300
+
301
+ if failures:
302
+ print(f"FAILURES ({len(failures)}):")
303
+ for f in failures:
304
+ print(" -", f)
305
+ return 1
306
+ print(
307
+ f"PASS: statsmapped-mcp client -- list_datasets/list_areas/"
308
+ f"list_area_datasets/get_dataset_for_area/rank_areas/list_comparisons/"
309
+ f"get_comparison/check_comparability/explain_metric all verified against "
310
+ f"the live API at {client.BASE_URL}, for both country='ireland' (default) "
311
+ f"and country='united-kingdom'. "
312
+ f"Caveat projection strips bodies in the list tool and keeps them in the "
313
+ f"detail tool; history_months converts to real periods; rank_areas sorts "
314
+ f"correctly and returns empty (not wrong) on a level mismatch; "
315
+ f"get_comparison strips raw scatter points and carries attribution; "
316
+ f"check_comparability confirms a real registered pair and refuses an "
317
+ f"unregistered one with a stated reason either way (never a bare "
318
+ f"true/false); explain_metric returns definition/unit/periodicity and "
319
+ f"never a current figure; a country/area_id mismatch, an unknown "
320
+ f"pair_key, an unknown stat_key and an invalid country all raise "
321
+ f"StatsMappedAPIError."
322
+ )
323
+ return 0
324
+
325
+
326
+ if __name__ == "__main__":
327
+ raise SystemExit(main())