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.
- statsmapped_mcp-0.1.0/.github/workflows/publish.yml +43 -0
- statsmapped_mcp-0.1.0/.gitignore +9 -0
- statsmapped_mcp-0.1.0/LICENSE +21 -0
- statsmapped_mcp-0.1.0/PKG-INFO +145 -0
- statsmapped_mcp-0.1.0/README.md +124 -0
- statsmapped_mcp-0.1.0/pyproject.toml +42 -0
- statsmapped_mcp-0.1.0/src/statsmapped_mcp/__init__.py +3 -0
- statsmapped_mcp-0.1.0/src/statsmapped_mcp/client.py +348 -0
- statsmapped_mcp-0.1.0/src/statsmapped_mcp/server.py +208 -0
- statsmapped_mcp-0.1.0/tests/test_client.py +327 -0
|
@@ -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,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,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())
|