whatdotheyknow-mcp 0.1.2__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,32 @@
1
+ name: Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ name: Publish to PyPI
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: astral-sh/setup-uv@v5
17
+ - run: uv build
18
+ - uses: pypa/gh-action-pypi-publish@release/v1
19
+ with:
20
+ skip_existing: true
21
+
22
+ deploy:
23
+ name: Deploy to Fly
24
+ runs-on: ubuntu-latest
25
+ needs: publish
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: superfly/flyctl-actions/setup-flyctl@master
29
+ - run: flyctl deploy --remote-only --ha=false
30
+ env:
31
+ FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
32
+
@@ -0,0 +1,15 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Scratch files
13
+ introduction-to-model-context-protocol.md
14
+ model-context-protocol-advanced-topics.md
15
+ tests/fleet_token_audit.py
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,50 @@
1
+ # AGENTS.md — whatdotheyknow-mcp
2
+
3
+ AI agent instructions for working in this repo. See `/home/bch/dev/ops/OPS.md` for credentials, fleet overview, and release tooling.
4
+
5
+ ## Repo shape
6
+
7
+ Single `server.py`. Tools for searching FOI requests, authorities, and drafting WhatDoTheyKnow submissions.
8
+ Not on PyPI — deployed to Fly only.
9
+
10
+ ## Deploy
11
+
12
+ ```bash
13
+ fly deploy --ha=false
14
+ ```
15
+
16
+ Single instance, lhr region. App name: `whatdotheyknow-mcp`. Fly.io account: articat1066@gmail.com.
17
+
18
+ ## Version bump
19
+
20
+ 1. Update `version` in `pyproject.toml`
21
+ 2. Update version string in the `smithery_server_card` route in `server.py`
22
+ 3. Commit and push (no PyPI release needed)
23
+ 4. `fly deploy --ha=false`
24
+ 5. Cut a new Glama release
25
+
26
+ ## Standard routes (must always be present)
27
+
28
+ - `/.well-known/mcp/server-card.json` — Smithery metadata
29
+ - `/.well-known/glama.json` — Glama maintainer claim
30
+ - `/health` — Fly health check
31
+
32
+ Verify after deploy:
33
+ ```bash
34
+ curl https://whatdotheyknow-mcp.fly.dev/.well-known/mcp/server-card.json
35
+ curl https://whatdotheyknow-mcp.fly.dev/.well-known/glama.json
36
+ curl https://whatdotheyknow-mcp.fly.dev/health
37
+ ```
38
+
39
+ ## README badge order
40
+
41
+ ```
42
+ SafeSkill → Glama card → Smithery
43
+ ```
44
+ (No PyPI badge — not published to PyPI)
45
+
46
+ ## Do not
47
+
48
+ - Do not use `FASTMCP_PORT` — the server reads `PORT` env var only
49
+ - Do not set `internal_port` in fly.toml to anything other than 8080
50
+ - Do not commit API keys — all secrets are in Fly secrets (`fly secrets list`)
@@ -0,0 +1,39 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Project
6
+
7
+ An MCP server exposing [WhatDoTheyKnow](https://www.whatdotheyknow.com) FOI request data to Claude and other MCP clients. Built with FastMCP on Python 3.13+.
8
+
9
+ ## Commands
10
+
11
+ This project uses `uv` for dependency management.
12
+
13
+ ```bash
14
+ # Install dependencies
15
+ uv sync
16
+
17
+ # Run the server (listens on http://127.0.0.1:9000)
18
+ uv run server.py
19
+
20
+ # Type checking
21
+ uv run mypy server.py
22
+
23
+ # Add a dependency
24
+ uv add <package>
25
+ ```
26
+
27
+ ## Architecture
28
+
29
+ All logic lives in [server.py](server.py). `main.py` is an unused stub.
30
+
31
+ **`WDTKClient`** — thin `httpx.AsyncClient` wrapper around `https://www.whatdotheyknow.com`. Write operations require `WDTK_API_KEY` env var and POST via `multipart/form-data`.
32
+
33
+ **Resources** (`wdtk://...`) — read-only data mapped to WhatDoTheyKnow REST endpoints (authorities, requests, users, feeds, CSV). Exposed as tools via `ResourcesAsTools` transform.
34
+
35
+ **Tools** — `build_request_url` and `search_request_events` are public. `create_request_record` and `update_request_state` are tagged `"write"` and can be disabled at startup.
36
+
37
+ **Prompt** — `draft_foi_request` generates a system prompt guiding narrow, specific FOI request drafting.
38
+
39
+ **Write API toggle** — uncomment `mcp.disable(tags={"write"})` in `server.py` to run in read-only mode.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Paul Boucherat
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,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: whatdotheyknow-mcp
3
+ Version: 0.1.2
4
+ Summary: WhatDoTheyKnow MCP server — search and read UK FOI requests, authorities, and responses
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: curl-cffi>=0.15.0
8
+ Requires-Dist: fastmcp>=3.2.4
9
+ Requires-Dist: httpx>=0.28.1
10
+ Requires-Dist: prometheus-client==0.24.1
11
+ Requires-Dist: pydantic>=2.13.3
12
+ Description-Content-Type: text/markdown
13
+
14
+ # whatdotheyknow-mcp
15
+
16
+ <!-- mcp-name: io.github.paulieb89/whatdotheyknow-mcp -->
17
+
18
+ [![Glama](https://img.shields.io/badge/Glama-listed-orange?style=flat-square)](https://glama.ai/mcp/servers/paulieb89/whatdotheyknow-mcp)
19
+ [![smithery badge](https://smithery.ai/badge/bouch/whatdotheyknow)](https://smithery.ai/servers/bouch/whatdotheyknow)
20
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=whatdotheyknow&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fwhatdotheyknow-mcp.fly.dev%2Fmcp%22%7D)
21
+ [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=whatdotheyknow&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fwhatdotheyknow-mcp.fly.dev%2Fmcp%22%7D&quality=insiders)
22
+ [![Install in Cursor](https://img.shields.io/badge/Cursor-Install_Server-000000?style=flat-square&logoColor=white)](https://cursor.com/en/install-mcp?name=whatdotheyknow&config=eyJ0eXBlIjoiaHR0cCIsInVybCI6Imh0dHBzOi8vd2hhdGRvdGhleWtub3ctbWNwLmZseS5kZXYvbWNwIn0=)
23
+ [![Install in VS Code (local)](https://img.shields.io/badge/VS_Code-Install_Local-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=whatdotheyknow&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22whatdotheyknow-mcp%22%5D%7D)
24
+
25
+ A Model Context Protocol server for UK Freedom of Information research. Connects AI assistants to [WhatDoTheyKnow](https://www.whatdotheyknow.com/) — the UK's largest FOI request platform — to search requests, read responses, look up public authorities, and draft new requests.
26
+
27
+ ## Tools
28
+
29
+ | Tool | Description |
30
+ |------|-------------|
31
+ | `search_request_events` | Full-text search of FOI requests and responses via WhatDoTheyKnow's Atom feed. Supports structured expressions (`status:successful`, `body:"Liverpool City Council"`). |
32
+ | `search_authorities` | Search UK public authorities by name. Returns slug for use with other tools. |
33
+ | `get_request_feed_items` | Fetch the event timeline (sent, response, clarification) for a specific FOI request. |
34
+ | `build_request_url` | Build a prefilled WhatDoTheyKnow request URL for a given authority and topic. |
35
+ | `create_request_record` | Create a request via the write API (requires `WDTK_API_KEY`). |
36
+ | `update_request_state` | Update user-assessed state of a request (requires `WDTK_API_KEY`). |
37
+
38
+ ## Resources
39
+
40
+ | URI template | Returns |
41
+ |---|---|
42
+ | `wdtk://authorities/{authority_slug}` | Authority profile JSON |
43
+ | `wdtk://requests/{request_slug}` | FOI request detail JSON |
44
+ | `wdtk://users/{user_slug}` | User profile JSON |
45
+ | `wdtk://requests/{request_slug}/feed` | Request event Atom feed |
46
+ | `wdtk://users/{user_slug}/feed` | User activity Atom feed |
47
+ | `wdtk://authorities/all.csv` | Full CSV of all UK public authorities |
48
+
49
+ ## Prompts
50
+
51
+ | Prompt | Description |
52
+ |--------|-------------|
53
+ | `draft_foi_request` | Draft a narrow, specific FOI request for a given authority and topic. |
54
+
55
+ ## Connect
56
+
57
+ ### Hosted (no install)
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "whatdotheyknow": {
63
+ "type": "http",
64
+ "url": "https://whatdotheyknow-mcp.fly.dev/mcp"
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ ### Local (uvx)
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "whatdotheyknow": {
76
+ "type": "stdio",
77
+ "command": "uvx",
78
+ "args": ["whatdotheyknow-mcp"]
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ ## Environment variables
85
+
86
+ | Variable | Required | Description |
87
+ |----------|----------|-------------|
88
+ | `WDTK_API_KEY` | Optional | Enables `create_request_record` and `update_request_state` write tools |
89
+
90
+ ## Upstream API and Licence
91
+
92
+ | Source | API | Licence | Auth |
93
+ |--------|-----|---------|------|
94
+ | WhatDoTheyKnow | `www.whatdotheyknow.com` | [OGL v3](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/) | None (read) / API key (write) |
95
+
96
+ Data is sourced directly from the WhatDoTheyKnow public API. The platform is operated by mySociety.
@@ -0,0 +1,83 @@
1
+ # whatdotheyknow-mcp
2
+
3
+ <!-- mcp-name: io.github.paulieb89/whatdotheyknow-mcp -->
4
+
5
+ [![Glama](https://img.shields.io/badge/Glama-listed-orange?style=flat-square)](https://glama.ai/mcp/servers/paulieb89/whatdotheyknow-mcp)
6
+ [![smithery badge](https://smithery.ai/badge/bouch/whatdotheyknow)](https://smithery.ai/servers/bouch/whatdotheyknow)
7
+ [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=whatdotheyknow&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fwhatdotheyknow-mcp.fly.dev%2Fmcp%22%7D)
8
+ [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=whatdotheyknow&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fwhatdotheyknow-mcp.fly.dev%2Fmcp%22%7D&quality=insiders)
9
+ [![Install in Cursor](https://img.shields.io/badge/Cursor-Install_Server-000000?style=flat-square&logoColor=white)](https://cursor.com/en/install-mcp?name=whatdotheyknow&config=eyJ0eXBlIjoiaHR0cCIsInVybCI6Imh0dHBzOi8vd2hhdGRvdGhleWtub3ctbWNwLmZseS5kZXYvbWNwIn0=)
10
+ [![Install in VS Code (local)](https://img.shields.io/badge/VS_Code-Install_Local-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=whatdotheyknow&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22whatdotheyknow-mcp%22%5D%7D)
11
+
12
+ A Model Context Protocol server for UK Freedom of Information research. Connects AI assistants to [WhatDoTheyKnow](https://www.whatdotheyknow.com/) — the UK's largest FOI request platform — to search requests, read responses, look up public authorities, and draft new requests.
13
+
14
+ ## Tools
15
+
16
+ | Tool | Description |
17
+ |------|-------------|
18
+ | `search_request_events` | Full-text search of FOI requests and responses via WhatDoTheyKnow's Atom feed. Supports structured expressions (`status:successful`, `body:"Liverpool City Council"`). |
19
+ | `search_authorities` | Search UK public authorities by name. Returns slug for use with other tools. |
20
+ | `get_request_feed_items` | Fetch the event timeline (sent, response, clarification) for a specific FOI request. |
21
+ | `build_request_url` | Build a prefilled WhatDoTheyKnow request URL for a given authority and topic. |
22
+ | `create_request_record` | Create a request via the write API (requires `WDTK_API_KEY`). |
23
+ | `update_request_state` | Update user-assessed state of a request (requires `WDTK_API_KEY`). |
24
+
25
+ ## Resources
26
+
27
+ | URI template | Returns |
28
+ |---|---|
29
+ | `wdtk://authorities/{authority_slug}` | Authority profile JSON |
30
+ | `wdtk://requests/{request_slug}` | FOI request detail JSON |
31
+ | `wdtk://users/{user_slug}` | User profile JSON |
32
+ | `wdtk://requests/{request_slug}/feed` | Request event Atom feed |
33
+ | `wdtk://users/{user_slug}/feed` | User activity Atom feed |
34
+ | `wdtk://authorities/all.csv` | Full CSV of all UK public authorities |
35
+
36
+ ## Prompts
37
+
38
+ | Prompt | Description |
39
+ |--------|-------------|
40
+ | `draft_foi_request` | Draft a narrow, specific FOI request for a given authority and topic. |
41
+
42
+ ## Connect
43
+
44
+ ### Hosted (no install)
45
+
46
+ ```json
47
+ {
48
+ "mcpServers": {
49
+ "whatdotheyknow": {
50
+ "type": "http",
51
+ "url": "https://whatdotheyknow-mcp.fly.dev/mcp"
52
+ }
53
+ }
54
+ }
55
+ ```
56
+
57
+ ### Local (uvx)
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "whatdotheyknow": {
63
+ "type": "stdio",
64
+ "command": "uvx",
65
+ "args": ["whatdotheyknow-mcp"]
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ ## Environment variables
72
+
73
+ | Variable | Required | Description |
74
+ |----------|----------|-------------|
75
+ | `WDTK_API_KEY` | Optional | Enables `create_request_record` and `update_request_state` write tools |
76
+
77
+ ## Upstream API and Licence
78
+
79
+ | Source | API | Licence | Auth |
80
+ |--------|-----|---------|------|
81
+ | WhatDoTheyKnow | `www.whatdotheyknow.com` | [OGL v3](https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/) | None (read) / API key (write) |
82
+
83
+ Data is sourced directly from the WhatDoTheyKnow public API. The platform is operated by mySociety.
@@ -0,0 +1,22 @@
1
+ # WhatDoTheyKnow MCP — Audit TODO
2
+
3
+ Tracked from canonical-fitness audit 2026-04-23.
4
+
5
+ ## Fixes
6
+
7
+ - [x] Add `destructiveHint=True` to `create_request_record` and `update_request_state`
8
+ - [x] Improve `all_authorities_csv` description (warn LLM of size, suggest alternatives)
9
+ - [x] Improve `get_request_feed_items` docstring (explain why it exists alongside raw feed resource)
10
+ - [x] Add next-step hint to `search_request_events` docstring
11
+ - [x] Add `search_authorities(query, limit)` tool — bounded authority lookup
12
+
13
+ ## Deferred
14
+
15
+ - [ ] Add `ResponseCachingMiddleware` for read-only tools and resources
16
+
17
+ ## Will Not Fix
18
+
19
+ - `CurrentContext()` injection — docs confirm both `ctx: Context` and
20
+ `ctx: Context = CurrentContext()` are valid; explicit form is intentional here.
21
+ - `-> str` + `json.dumps()` in resources — correct pattern per fastmcp docs;
22
+ anti-pattern only applies to tools (all tools already return Pydantic/dict).
@@ -0,0 +1,6 @@
1
+ def main():
2
+ print("Hello from whatdotheyknow-mcp!")
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "whatdotheyknow-mcp"
7
+ version = "0.1.2"
8
+ description = "WhatDoTheyKnow MCP server — search and read UK FOI requests, authorities, and responses"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "curl-cffi>=0.15.0",
13
+ "fastmcp>=3.2.4",
14
+ "httpx>=0.28.1",
15
+ "pydantic>=2.13.3",
16
+ "prometheus-client==0.24.1",
17
+ ]
18
+
19
+ [project.scripts]
20
+ whatdotheyknow-mcp = "server:main"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ only-include = ["server.py", "main.py"]
24
+
25
+ [tool.hatch.build.targets.sdist]
26
+ exclude = ["fly.toml", "Dockerfile", "server.json", ".mcp.json", "dist/", "tests/", "uv.lock"]
@@ -0,0 +1,506 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import json
5
+ import os
6
+ import time
7
+ from typing import Any
8
+ from urllib.parse import quote, urlencode
9
+ from xml.etree import ElementTree as ET
10
+
11
+ import httpx
12
+ from curl_cffi.requests import AsyncSession
13
+ from prometheus_client import CONTENT_TYPE_LATEST, Counter as PromCounter, Histogram, generate_latest
14
+ from pydantic import BaseModel, Field, HttpUrl
15
+ from starlette.responses import JSONResponse, Response
16
+
17
+ from fastmcp import FastMCP
18
+ from fastmcp.dependencies import CurrentContext
19
+ from fastmcp.server.context import Context
20
+ from fastmcp.server.transforms import PromptsAsTools
21
+ from mcp.types import ToolAnnotations
22
+
23
+
24
+ BASE_URL = "https://www.whatdotheyknow.com"
25
+
26
+ TRANSPORT = os.getenv("FASTMCP_TRANSPORT", "http")
27
+ REGION = os.getenv("FLY_REGION", "local")
28
+
29
+ tool_calls_total = PromCounter(
30
+ "whatdotheyknow_tool_calls_total",
31
+ "Count of MCP tool invocations.",
32
+ labelnames=["tool", "transport", "region", "status"],
33
+ )
34
+ tool_duration_seconds = Histogram(
35
+ "whatdotheyknow_tool_duration_seconds",
36
+ "Tool invocation latency in seconds.",
37
+ labelnames=["tool", "transport", "region"],
38
+ buckets=(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0),
39
+ )
40
+
41
+
42
+ def _timed_tool(fn):
43
+ tool_name = fn.__name__
44
+
45
+ @functools.wraps(fn)
46
+ async def wrapped(*args, **kwargs):
47
+ t0 = time.perf_counter()
48
+ try:
49
+ result = await fn(*args, **kwargs)
50
+ tool_calls_total.labels(tool_name, TRANSPORT, REGION, "ok").inc()
51
+ return result
52
+ except BaseException:
53
+ tool_calls_total.labels(tool_name, TRANSPORT, REGION, "error").inc()
54
+ raise
55
+ finally:
56
+ tool_duration_seconds.labels(tool_name, TRANSPORT, REGION).observe(
57
+ time.perf_counter() - t0
58
+ )
59
+
60
+ return wrapped
61
+
62
+
63
+ class NewRequestLink(BaseModel):
64
+ authority_slug: str
65
+ url: HttpUrl
66
+
67
+
68
+ class AtomEntry(BaseModel):
69
+ id: str | None = None
70
+ title: str | None = None
71
+ link: str | None = None
72
+ updated: str | None = None
73
+ summary: str | None = None
74
+
75
+
76
+ class CreateRequestPayload(BaseModel):
77
+ title: str
78
+ body: str
79
+ external_user_name: str
80
+ external_url: HttpUrl
81
+
82
+
83
+ class AddCorrespondencePayload(BaseModel):
84
+ direction: str = Field(pattern="^(request|response)$")
85
+ body: str
86
+ sent_at: str
87
+ state: str | None = Field(
88
+ default=None,
89
+ pattern="^(waiting_response|rejected|successful|partially_successful)$",
90
+ )
91
+
92
+
93
+ class UpdateRequestStatePayload(BaseModel):
94
+ state: str = Field(pattern="^(waiting_response|rejected|successful|partially_successful)$")
95
+
96
+
97
+ class AuthorityResult(BaseModel):
98
+ name: str
99
+ short_name: str
100
+ slug: str
101
+ tags: str | None = None
102
+
103
+
104
+ class WDTKClient:
105
+ def __init__(self, base_url: str = BASE_URL, timeout: float = 20.0) -> None:
106
+ self.base_url = base_url.rstrip("/")
107
+ self.timeout = timeout
108
+
109
+ async def get_json(self, path: str) -> dict[str, Any]:
110
+ async with AsyncSession(impersonate="safari17_0") as client:
111
+ response = await client.get(
112
+ f"{self.base_url}{path}",
113
+ headers={"Accept": "application/json"},
114
+ timeout=self.timeout,
115
+ )
116
+ response.raise_for_status()
117
+ return response.json()
118
+
119
+ async def get_text(self, path: str, accept: str | None = None) -> str:
120
+ async with AsyncSession(impersonate="safari17_0") as client:
121
+ headers = {"Accept": accept} if accept else {}
122
+ response = await client.get(
123
+ f"{self.base_url}{path}",
124
+ headers=headers,
125
+ timeout=self.timeout,
126
+ )
127
+ response.raise_for_status()
128
+ return response.text
129
+
130
+ async def post_form_json(
131
+ self,
132
+ path: str,
133
+ *,
134
+ api_key: str,
135
+ json_payload: dict[str, Any],
136
+ files: list[tuple[str, tuple[str, bytes, str]]] | None = None,
137
+ ) -> dict[str, Any]:
138
+ data = {"json": json.dumps(json_payload)}
139
+ params = {"k": api_key}
140
+
141
+ async with httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout) as client:
142
+ response = await client.post(path, params=params, data=data, files=files)
143
+ response.raise_for_status()
144
+ return response.json()
145
+
146
+
147
+ def parse_atom(xml_text: str) -> list[AtomEntry]:
148
+ ns = {"atom": "http://www.w3.org/2005/Atom"}
149
+ root = ET.fromstring(xml_text)
150
+ entries: list[AtomEntry] = []
151
+
152
+ for entry in root.findall("atom:entry", ns):
153
+ link_el = entry.find("atom:link", ns)
154
+ summary_el = entry.find("atom:summary", ns)
155
+ entries.append(
156
+ AtomEntry(
157
+ id=(entry.findtext("atom:id", default=None, namespaces=ns)),
158
+ title=(entry.findtext("atom:title", default=None, namespaces=ns)),
159
+ link=(link_el.get("href") if link_el is not None else None),
160
+ updated=(entry.findtext("atom:updated", default=None, namespaces=ns)),
161
+ summary=("".join(summary_el.itertext()).strip() if summary_el is not None else None),
162
+ )
163
+ )
164
+
165
+ return entries
166
+
167
+
168
+ wdtk = WDTKClient()
169
+
170
+ mcp = FastMCP(
171
+ name="WhatDoTheyKnow MCP",
172
+ instructions=(
173
+ "Read WhatDoTheyKnow public JSON, Atom feeds, and CSV exports. "
174
+ "Optionally call the experimental write API if configured."
175
+ ),
176
+ mask_error_details=True,
177
+ )
178
+
179
+
180
+ # -------------------------
181
+ # Resources: read-only data
182
+ # -------------------------
183
+
184
+ @mcp.resource("wdtk://authorities/{authority_slug}", mime_type="application/json")
185
+ async def authority_json(authority_slug: str, ctx: Context = CurrentContext()) -> str:
186
+ """Read a public authority as JSON."""
187
+ await ctx.info(f"Fetching authority JSON: {authority_slug}")
188
+ payload = await wdtk.get_json(f"/body/{authority_slug}.json")
189
+ return json.dumps(payload, ensure_ascii=False, indent=2)
190
+
191
+
192
+ @mcp.resource("wdtk://requests/{request_slug}", mime_type="application/json")
193
+ async def request_json(request_slug: str, ctx: Context = CurrentContext()) -> str:
194
+ """Read a request as JSON."""
195
+ await ctx.info(f"Fetching request JSON: {request_slug}")
196
+ payload = await wdtk.get_json(f"/request/{request_slug}.json")
197
+ return json.dumps(payload, ensure_ascii=False, indent=2)
198
+
199
+
200
+ @mcp.resource("wdtk://users/{user_slug}", mime_type="application/json")
201
+ async def user_json(user_slug: str, ctx: Context = CurrentContext()) -> str:
202
+ """Read a user as JSON."""
203
+ await ctx.info(f"Fetching user JSON: {user_slug}")
204
+ payload = await wdtk.get_json(f"/user/{user_slug}.json")
205
+ return json.dumps(payload, ensure_ascii=False, indent=2)
206
+
207
+
208
+ @mcp.resource("wdtk://requests/{request_slug}/feed", mime_type="application/atom+xml")
209
+ async def request_feed_xml(request_slug: str, ctx: Context = CurrentContext()) -> str:
210
+ """Read a request Atom feed as raw XML."""
211
+ await ctx.info(f"Fetching request feed: {request_slug}")
212
+ return await wdtk.get_text(
213
+ f"/request/{request_slug}/feed",
214
+ accept="application/atom+xml, application/xml;q=0.9, */*;q=0.1",
215
+ )
216
+
217
+
218
+ @mcp.resource("wdtk://users/{user_slug}/feed", mime_type="application/atom+xml")
219
+ async def user_feed_xml(user_slug: str, ctx: Context = CurrentContext()) -> str:
220
+ """Read a user Atom feed as raw XML."""
221
+ await ctx.info(f"Fetching user feed: {user_slug}")
222
+ return await wdtk.get_text(
223
+ f"/feed/user/{user_slug}",
224
+ accept="application/atom+xml, application/xml;q=0.9, */*;q=0.1",
225
+ )
226
+
227
+
228
+ @mcp.resource("wdtk://authorities/all.csv", mime_type="text/csv")
229
+ async def all_authorities_csv(ctx: Context = CurrentContext()) -> str:
230
+ """Download the complete CSV of every WhatDoTheyKnow public authority.
231
+ WARNING: this is a large payload — use search_authorities(query) for targeted
232
+ lookups, or authority_json for a specific body. Only call this when you need
233
+ the full dataset (e.g. bulk analysis or seeding a list)."""
234
+ await ctx.info("Fetching all-authorities CSV")
235
+ return await wdtk.get_text("/body/all-authorities.csv", accept="text/csv, */*;q=0.1")
236
+
237
+
238
+ # -------------------------
239
+ # Tools: operations/helpers
240
+ # -------------------------
241
+
242
+ @mcp.tool(
243
+ annotations=ToolAnnotations(
244
+ readOnlyHint=True,
245
+ idempotentHint=True,
246
+ openWorldHint=True,
247
+ ),
248
+ tags={"public", "compose"},
249
+ )
250
+ def build_request_url(
251
+ authority_slug: str,
252
+ title: str | None = None,
253
+ default_letter: str | None = None,
254
+ body: str | None = None,
255
+ tags: list[str] | None = None,
256
+ ) -> NewRequestLink:
257
+ """Build a prefilled WhatDoTheyKnow request URL."""
258
+ params: dict[str, str] = {}
259
+ if title:
260
+ params["title"] = title
261
+ if default_letter:
262
+ params["default_letter"] = default_letter
263
+ if body:
264
+ params["body"] = body
265
+ if tags:
266
+ params["tags"] = " ".join(tags)
267
+
268
+ query = urlencode(params, doseq=False)
269
+ url = f"{BASE_URL}/new/{authority_slug}"
270
+ if query:
271
+ url = f"{url}?{query}"
272
+
273
+ return NewRequestLink(authority_slug=authority_slug, url=url)
274
+
275
+
276
+ @mcp.tool(
277
+ annotations=ToolAnnotations(
278
+ readOnlyHint=True,
279
+ idempotentHint=True,
280
+ openWorldHint=True,
281
+ ),
282
+ tags={"public", "feed"},
283
+ )
284
+ @_timed_tool
285
+ async def get_request_feed_items(
286
+ request_slug: str,
287
+ limit: int = 20,
288
+ ctx: Context = CurrentContext(),
289
+ ) -> list[AtomEntry]:
290
+ """Return parsed Atom feed entries for a specific FOI request as structured objects.
291
+
292
+ Use this instead of reading the raw wdtk://requests/{slug}/feed resource when you
293
+ want structured AtomEntry objects rather than raw XML. Each entry's `link` field
294
+ contains the request URL; use the slug from that URL with request_json or
295
+ authority_json for full detail."""
296
+ await ctx.info(f"Parsing request feed for: {request_slug}")
297
+ xml_text = await wdtk.get_text(
298
+ f"/request/{request_slug}/feed",
299
+ accept="application/atom+xml, application/xml;q=0.9, */*;q=0.1",
300
+ )
301
+ items = parse_atom(xml_text)
302
+ return items[:limit]
303
+
304
+
305
+ @mcp.tool(
306
+ annotations=ToolAnnotations(
307
+ readOnlyHint=True,
308
+ idempotentHint=True,
309
+ openWorldHint=True,
310
+ ),
311
+ tags={"public", "search"},
312
+ )
313
+ @_timed_tool
314
+ async def search_request_events(
315
+ search_expression: str,
316
+ limit: int = 20,
317
+ ctx: Context = CurrentContext(),
318
+ ) -> list[AtomEntry]:
319
+ """Search WhatDoTheyKnow's feed-based event index and return structured results.
320
+
321
+ Call this to find FOI requests matching a query expression. Returns up to `limit`
322
+ AtomEntry objects. Use the `link` field of each result as the next navigation
323
+ step — extract the request slug and call the wdtk://requests/{slug} resource or
324
+ get_request_feed_items for full detail.
325
+
326
+ Example expressions:
327
+ status:successful
328
+ body:"Liverpool City Council"
329
+ (variety:sent OR variety:response) status:successful
330
+ """
331
+ await ctx.info(f"Searching WDTK feed with expression: {search_expression}")
332
+ encoded = quote(search_expression, safe="")
333
+ xml_text = await wdtk.get_text(
334
+ f"/feed/search/{encoded}",
335
+ accept="application/atom+xml, application/xml;q=0.9, */*;q=0.1",
336
+ )
337
+ items = parse_atom(xml_text)
338
+ return items[:limit]
339
+
340
+
341
+ @mcp.tool(
342
+ annotations=ToolAnnotations(
343
+ readOnlyHint=True,
344
+ idempotentHint=True,
345
+ openWorldHint=True,
346
+ ),
347
+ tags={"public", "search"},
348
+ )
349
+ @_timed_tool
350
+ async def search_authorities(
351
+ query: str,
352
+ limit: int = 20,
353
+ ctx: Context = CurrentContext(),
354
+ ) -> list[AuthorityResult]:
355
+ """Search WhatDoTheyKnow public authorities by name.
356
+
357
+ Returns up to `limit` authorities whose name or short_name contains `query`
358
+ (case-insensitive). Use the `slug` field with authority_json or
359
+ build_request_url as the next step.
360
+
361
+ Example: search_authorities("Liverpool") → slug "liverpool_city_council"
362
+ Then: authority_json with that slug, or build_request_url with it."""
363
+ import csv
364
+ import io
365
+
366
+ await ctx.info(f"Searching authorities for: {query}")
367
+ csv_text = await wdtk.get_text("/body/all-authorities.csv", accept="text/csv, */*;q=0.1")
368
+ reader = csv.DictReader(io.StringIO(csv_text))
369
+ q = query.lower()
370
+ results: list[AuthorityResult] = []
371
+ for row in reader:
372
+ if q in row.get("Name", "").lower() or q in row.get("Short name", "").lower():
373
+ results.append(
374
+ AuthorityResult(
375
+ name=row.get("Name", ""),
376
+ short_name=row.get("Short name", ""),
377
+ slug=row.get("URL name", ""),
378
+ tags=row.get("Tags") or None,
379
+ )
380
+ )
381
+ if len(results) >= limit:
382
+ break
383
+ return results
384
+
385
+
386
+ @mcp.tool(
387
+ annotations=ToolAnnotations(destructiveHint=True, openWorldHint=True),
388
+ tags={"write", "admin"},
389
+ )
390
+ @_timed_tool
391
+ async def create_request_record(
392
+ title: str,
393
+ body: str,
394
+ external_user_name: str,
395
+ external_url: str,
396
+ ctx: Context = CurrentContext(),
397
+ ) -> dict[str, Any]:
398
+ """
399
+ Create a request through the experimental write API.
400
+
401
+ Requires WDTK_API_KEY in the server environment.
402
+ """
403
+ api_key = os.getenv("WDTK_API_KEY")
404
+ if not api_key:
405
+ return {"error": "Write API unavailable: WDTK_API_KEY not configured. Requires an authority-level key from the WhatDoTheyKnow admin interface."}
406
+ payload = CreateRequestPayload(
407
+ title=title,
408
+ body=body,
409
+ external_user_name=external_user_name,
410
+ external_url=external_url,
411
+ )
412
+ await ctx.info(f"Creating request record for {external_user_name}")
413
+ return await wdtk.post_form_json(
414
+ "/api/v2/request",
415
+ api_key=api_key,
416
+ json_payload=payload.model_dump(mode="json"),
417
+ )
418
+
419
+
420
+ @mcp.tool(
421
+ annotations=ToolAnnotations(destructiveHint=True, openWorldHint=True),
422
+ tags={"write", "admin"},
423
+ )
424
+ @_timed_tool
425
+ async def update_request_state(
426
+ request_id: int,
427
+ state: str,
428
+ ctx: Context = CurrentContext(),
429
+ ) -> dict[str, Any]:
430
+ """
431
+ Update the user-assessed state of a request through the experimental write API.
432
+
433
+ Requires WDTK_API_KEY in the server environment.
434
+ """
435
+ api_key = os.getenv("WDTK_API_KEY")
436
+ if not api_key:
437
+ return {"error": "Write API unavailable: WDTK_API_KEY not configured. Requires an authority-level key from the WhatDoTheyKnow admin interface."}
438
+ payload = UpdateRequestStatePayload(state=state)
439
+ await ctx.info(f"Updating request {request_id} to state={state}")
440
+ return await wdtk.post_form_json(
441
+ f"/api/v2/request/{request_id}/update.json",
442
+ api_key=api_key,
443
+ json_payload=payload.model_dump(mode="json"),
444
+ )
445
+
446
+
447
+ # -------------------------
448
+ # Prompt: optional workflow
449
+ # -------------------------
450
+
451
+ @mcp.prompt
452
+ def draft_foi_request(
453
+ authority_slug: str,
454
+ topic: str,
455
+ facts: str | None = None,
456
+ ) -> str:
457
+ """Draft a narrow, specific FOI request suitable for WhatDoTheyKnow."""
458
+ extra = f"\nRelevant facts:\n{facts}\n" if facts else ""
459
+ return (
460
+ f"Draft a concise UK FOI request for authority '{authority_slug}' about '{topic}'.\n"
461
+ "Requirements:\n"
462
+ "- narrow scope\n"
463
+ "- precise date range if useful\n"
464
+ "- request existing recorded information only\n"
465
+ "- avoid arguments/opinions\n"
466
+ "- suitable for publication on WhatDoTheyKnow\n"
467
+ f"{extra}"
468
+ )
469
+
470
+
471
+ mcp.add_transform(PromptsAsTools(mcp))
472
+
473
+ # Optional: keep admin/write tools hidden on the public server.
474
+ # mcp.disable(tags={"write"})
475
+
476
+
477
+ @mcp.custom_route("/.well-known/mcp/server-card.json", methods=["GET"])
478
+ async def smithery_server_card(request):
479
+ return JSONResponse({"serverInfo": {"name": "whatdotheyknow-mcp", "version": "0.1.1"}})
480
+
481
+
482
+ @mcp.custom_route("/.well-known/glama.json", methods=["GET"])
483
+ async def glama_claim(request):
484
+ return JSONResponse({
485
+ "$schema": "https://glama.ai/mcp/schemas/connector.json",
486
+ "maintainers": [{"email": "paul@bouch.dev"}],
487
+ })
488
+
489
+
490
+ @mcp.custom_route("/health", methods=["GET"])
491
+ async def health_check(request):
492
+ return JSONResponse({"status": "healthy"})
493
+
494
+
495
+ @mcp.custom_route("/metrics", methods=["GET"])
496
+ async def metrics_endpoint(request):
497
+ return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
498
+
499
+
500
+ def main() -> None:
501
+ port = int(os.environ.get("PORT", "9000"))
502
+ mcp.run(transport="streamable-http", host="0.0.0.0", port=port, stateless_http=True)
503
+
504
+
505
+ if __name__ == "__main__":
506
+ main()