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.
- whatdotheyknow_mcp-0.1.2/.github/workflows/release.yml +32 -0
- whatdotheyknow_mcp-0.1.2/.gitignore +15 -0
- whatdotheyknow_mcp-0.1.2/.python-version +1 -0
- whatdotheyknow_mcp-0.1.2/AGENTS.md +50 -0
- whatdotheyknow_mcp-0.1.2/CLAUDE.md +39 -0
- whatdotheyknow_mcp-0.1.2/LICENSE +21 -0
- whatdotheyknow_mcp-0.1.2/PKG-INFO +96 -0
- whatdotheyknow_mcp-0.1.2/README.md +83 -0
- whatdotheyknow_mcp-0.1.2/TODO.md +22 -0
- whatdotheyknow_mcp-0.1.2/main.py +6 -0
- whatdotheyknow_mcp-0.1.2/pyproject.toml +26 -0
- whatdotheyknow_mcp-0.1.2/server.py +506 -0
|
@@ -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
|
+
[](https://glama.ai/mcp/servers/paulieb89/whatdotheyknow-mcp)
|
|
19
|
+
[](https://smithery.ai/servers/bouch/whatdotheyknow)
|
|
20
|
+
[](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
|
+
[](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
|
+
[](https://cursor.com/en/install-mcp?name=whatdotheyknow&config=eyJ0eXBlIjoiaHR0cCIsInVybCI6Imh0dHBzOi8vd2hhdGRvdGhleWtub3ctbWNwLmZseS5kZXYvbWNwIn0=)
|
|
23
|
+
[](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
|
+
[](https://glama.ai/mcp/servers/paulieb89/whatdotheyknow-mcp)
|
|
6
|
+
[](https://smithery.ai/servers/bouch/whatdotheyknow)
|
|
7
|
+
[](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
|
+
[](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
|
+
[](https://cursor.com/en/install-mcp?name=whatdotheyknow&config=eyJ0eXBlIjoiaHR0cCIsInVybCI6Imh0dHBzOi8vd2hhdGRvdGhleWtub3ctbWNwLmZseS5kZXYvbWNwIn0=)
|
|
10
|
+
[](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,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()
|