ukds-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.
- ukds_mcp-0.1.0/PKG-INFO +9 -0
- ukds_mcp-0.1.0/README.md +0 -0
- ukds_mcp-0.1.0/pyproject.toml +17 -0
- ukds_mcp-0.1.0/server.py +644 -0
- ukds_mcp-0.1.0/setup.cfg +4 -0
- ukds_mcp-0.1.0/ukds_mcp.egg-info/PKG-INFO +9 -0
- ukds_mcp-0.1.0/ukds_mcp.egg-info/SOURCES.txt +9 -0
- ukds_mcp-0.1.0/ukds_mcp.egg-info/dependency_links.txt +1 -0
- ukds_mcp-0.1.0/ukds_mcp.egg-info/entry_points.txt +2 -0
- ukds_mcp-0.1.0/ukds_mcp.egg-info/requires.txt +3 -0
- ukds_mcp-0.1.0/ukds_mcp.egg-info/top_level.txt +1 -0
ukds_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ukds-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for the UK Data Service — search datasets, manage projects, download microdata
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: mcp[cli]>=1.26.0
|
|
9
|
+
Requires-Dist: playwright>=1.58.0
|
ukds_mcp-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ukds-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for the UK Data Service — search datasets, manage projects, download microdata"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"httpx>=0.28.1",
|
|
9
|
+
"mcp[cli]>=1.26.0",
|
|
10
|
+
"playwright>=1.58.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[tool.setuptools]
|
|
14
|
+
py-modules = ["server"]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
ukds-mcp = "server:mcp.run"
|
ukds_mcp-0.1.0/server.py
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UK Data Service MCP server.
|
|
3
|
+
|
|
4
|
+
Two API surfaces:
|
|
5
|
+
- AppSync GraphQL (public, API-key auth) — catalogue search and metadata
|
|
6
|
+
- Umbraco Surface REST (session-cookie auth) — account, projects, downloads
|
|
7
|
+
|
|
8
|
+
Tools:
|
|
9
|
+
login — opens browser for Shibboleth sign-in; captures session cookies
|
|
10
|
+
search_datasets — search the catalogue (GraphQL, no login needed)
|
|
11
|
+
get_dataset — full metadata for a study (GraphQL, no login needed)
|
|
12
|
+
list_projects — list your UKDS projects
|
|
13
|
+
get_project — details of a specific project
|
|
14
|
+
list_access_requests — datasets you have active access to
|
|
15
|
+
list_downloads — files available for a study in a project
|
|
16
|
+
download_file — download a data file to a local path
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from mcp.server.fastmcp import FastMCP
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Constants
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
BETA_BASE = "https://beta.ukdataservice.ac.uk"
|
|
34
|
+
GRAPHQL_URL = "https://ohlhy6cg7nhwtpuer664aeok2i.appsync-api.eu-west-2.amazonaws.com/graphql"
|
|
35
|
+
GRAPHQL_API_KEY = "da2-dbqlla2y3jf2vaqev4lcrpiq4a"
|
|
36
|
+
SESSION_FILE = Path.home() / ".config" / "ukds-mcp" / "session.json"
|
|
37
|
+
|
|
38
|
+
mcp = FastMCP("ukds")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
# Session management (Umbraco cookie auth)
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def _load_session() -> dict[str, str]:
|
|
46
|
+
if SESSION_FILE.exists():
|
|
47
|
+
return json.loads(SESSION_FILE.read_text())
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _save_session(cookies: dict[str, str]) -> None:
|
|
52
|
+
SESSION_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
SESSION_FILE.write_text(json.dumps(cookies, indent=2))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _make_client(cookies: dict[str, str] | None = None) -> httpx.Client:
|
|
57
|
+
jar = httpx.Cookies()
|
|
58
|
+
for k, v in (cookies or _load_session()).items():
|
|
59
|
+
jar.set(k, v, domain="beta.ukdataservice.ac.uk")
|
|
60
|
+
return httpx.Client(
|
|
61
|
+
base_url=BETA_BASE,
|
|
62
|
+
cookies=jar,
|
|
63
|
+
headers={
|
|
64
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
65
|
+
"Accept": "application/json, text/plain, */*",
|
|
66
|
+
"Referer": BETA_BASE + "/",
|
|
67
|
+
},
|
|
68
|
+
follow_redirects=True,
|
|
69
|
+
timeout=30,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _check_auth(client: httpx.Client) -> bool:
|
|
74
|
+
r = client.get("/Umbraco/Surface/Login/KeepAlive")
|
|
75
|
+
return r.status_code == 200
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# GraphQL helper (catalogue — no login required)
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _gql(query: str, variables: dict | None = None) -> dict:
|
|
83
|
+
r = httpx.post(
|
|
84
|
+
GRAPHQL_URL,
|
|
85
|
+
headers={
|
|
86
|
+
"x-api-key": GRAPHQL_API_KEY,
|
|
87
|
+
"Content-Type": "application/json",
|
|
88
|
+
},
|
|
89
|
+
json={"query": query, "variables": variables or {}},
|
|
90
|
+
timeout=15,
|
|
91
|
+
)
|
|
92
|
+
r.raise_for_status()
|
|
93
|
+
return r.json()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Tools
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
@mcp.tool()
|
|
101
|
+
async def login() -> str:
|
|
102
|
+
"""
|
|
103
|
+
Open a browser window for you to sign in to the UK Data Service.
|
|
104
|
+
Completes the Shibboleth/institutional login flow and saves the session
|
|
105
|
+
automatically — you won't need to log in again until the session expires.
|
|
106
|
+
"""
|
|
107
|
+
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout
|
|
108
|
+
import asyncio
|
|
109
|
+
|
|
110
|
+
async with async_playwright() as pw:
|
|
111
|
+
browser = await pw.chromium.launch(headless=False, slow_mo=50)
|
|
112
|
+
ctx = await browser.new_context()
|
|
113
|
+
page = await ctx.new_page()
|
|
114
|
+
|
|
115
|
+
# Show a clear instruction overlay before navigating
|
|
116
|
+
async def show_banner(p):
|
|
117
|
+
try:
|
|
118
|
+
await p.evaluate("""() => {
|
|
119
|
+
const div = document.createElement('div');
|
|
120
|
+
div.id = 'ukds-mcp-banner';
|
|
121
|
+
div.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:999999;background:#1a73e8;color:#fff;font:bold 15px/40px sans-serif;text-align:center;padding:0 16px;';
|
|
122
|
+
div.textContent = 'Sign in to the UK Data Service — the browser will close automatically once you are logged in.';
|
|
123
|
+
document.body.prepend(div);
|
|
124
|
+
}""")
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
page.on("load", lambda: asyncio.ensure_future(show_banner(page)))
|
|
129
|
+
|
|
130
|
+
await page.goto(f"{BETA_BASE}/myaccount/login")
|
|
131
|
+
|
|
132
|
+
print("Browser opened — please sign in to the UK Data Service.")
|
|
133
|
+
print("The browser will close automatically once login is complete.")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
await page.wait_for_url(
|
|
137
|
+
lambda url: "ukdataservice.ac.uk/myaccount" in url.lower() and "login" not in url.lower(),
|
|
138
|
+
timeout=300_000,
|
|
139
|
+
)
|
|
140
|
+
except PlaywrightTimeout:
|
|
141
|
+
await browser.close()
|
|
142
|
+
return "Login timed out after 5 minutes. Please try again."
|
|
143
|
+
|
|
144
|
+
# Give the page a moment to fully settle and set all cookies
|
|
145
|
+
await asyncio.sleep(2)
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
all_cookies = await ctx.cookies()
|
|
149
|
+
except Exception:
|
|
150
|
+
all_cookies = []
|
|
151
|
+
|
|
152
|
+
session_cookies = {
|
|
153
|
+
c["name"]: c["value"]
|
|
154
|
+
for c in all_cookies
|
|
155
|
+
if "ukdataservice.ac.uk" in c.get("domain", "")
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
await browser.close()
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
print("Browser closed — login complete.")
|
|
163
|
+
|
|
164
|
+
if not session_cookies:
|
|
165
|
+
return "Login appeared to succeed but no session cookies were captured. Please try again."
|
|
166
|
+
|
|
167
|
+
_save_session(session_cookies)
|
|
168
|
+
|
|
169
|
+
with _make_client(session_cookies) as client:
|
|
170
|
+
if _check_auth(client):
|
|
171
|
+
return (
|
|
172
|
+
f"Logged in successfully. Session saved ({len(session_cookies)} cookies). "
|
|
173
|
+
"You're ready to use all UKDS tools."
|
|
174
|
+
)
|
|
175
|
+
else:
|
|
176
|
+
return "Cookies captured but auth check failed — the session may not be valid. Try logging in again."
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@mcp.tool()
|
|
180
|
+
def search_datasets(
|
|
181
|
+
query: str,
|
|
182
|
+
rows: int = 10,
|
|
183
|
+
date_from: int | None = None,
|
|
184
|
+
date_to: int | None = None,
|
|
185
|
+
) -> str:
|
|
186
|
+
"""
|
|
187
|
+
Search the UK Data Service catalogue. Does not require login.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
query: Search terms (e.g. "English Housing Survey", "labour market")
|
|
191
|
+
rows: Number of results to return (default 10, max 50)
|
|
192
|
+
date_from: Filter to studies from this year (e.g. 2015)
|
|
193
|
+
date_to: Filter to studies up to this year (e.g. 2023)
|
|
194
|
+
"""
|
|
195
|
+
rows = min(rows, 50)
|
|
196
|
+
|
|
197
|
+
gql = """
|
|
198
|
+
query SearchStudies($q: String, $rows: Int, $from: Int, $to: Int) {
|
|
199
|
+
getStudyList(QueryString: $q, Rows: $rows, DateFrom: $from, DateTo: $to, Start: 0, Sort: 0) {
|
|
200
|
+
Count
|
|
201
|
+
Results {
|
|
202
|
+
FriendlyId
|
|
203
|
+
Title
|
|
204
|
+
KindOfData
|
|
205
|
+
LatestEditionReleaseDate
|
|
206
|
+
Embargoed
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
"""
|
|
211
|
+
data = _gql(gql, {
|
|
212
|
+
"q": query,
|
|
213
|
+
"rows": rows,
|
|
214
|
+
"from": date_from,
|
|
215
|
+
"to": date_to,
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
if "errors" in data:
|
|
219
|
+
return f"Search error: {data['errors']}"
|
|
220
|
+
|
|
221
|
+
result = data["data"]["getStudyList"]
|
|
222
|
+
studies = result.get("Results") or []
|
|
223
|
+
total = result.get("Count", 0)
|
|
224
|
+
|
|
225
|
+
if not studies:
|
|
226
|
+
return f"No results found for '{query}'."
|
|
227
|
+
|
|
228
|
+
lines = [f"Found {total} studies for '{query}' (showing {len(studies)}):\n"]
|
|
229
|
+
for s in studies:
|
|
230
|
+
fid = s.get("FriendlyId", "")
|
|
231
|
+
title = s.get("Title", "Untitled")
|
|
232
|
+
embargoed = " [EMBARGOED]" if s.get("Embargoed") else ""
|
|
233
|
+
release = s.get("LatestEditionReleaseDate", "")[:10] if s.get("LatestEditionReleaseDate") else ""
|
|
234
|
+
release_str = f" (latest: {release})" if release else ""
|
|
235
|
+
lines.append(f"• SN {fid}: {title}{embargoed}{release_str}")
|
|
236
|
+
|
|
237
|
+
return "\n".join(lines)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@mcp.tool()
|
|
241
|
+
def get_dataset(study_number: str) -> str:
|
|
242
|
+
"""
|
|
243
|
+
Get full metadata for a dataset by its study number (FriendlyId). Does not require login.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
study_number: The study number as shown in search results (e.g. '9444')
|
|
247
|
+
"""
|
|
248
|
+
gql = """
|
|
249
|
+
query GetStudy($id: String) {
|
|
250
|
+
getStudyItem(FriendlyId: $id) {
|
|
251
|
+
Title
|
|
252
|
+
SubTitle
|
|
253
|
+
Abstract
|
|
254
|
+
DOI
|
|
255
|
+
TypeOfAccess
|
|
256
|
+
Status
|
|
257
|
+
FriendlyId
|
|
258
|
+
TimePeriod
|
|
259
|
+
TimePeriodStart
|
|
260
|
+
TimePeriodEnd
|
|
261
|
+
KindOfData
|
|
262
|
+
Country
|
|
263
|
+
Subject
|
|
264
|
+
Creator { Organisations Individuals }
|
|
265
|
+
Publisher
|
|
266
|
+
Keyword { Value }
|
|
267
|
+
DataFormat { Value }
|
|
268
|
+
SamplingProcedure
|
|
269
|
+
Universe
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
"""
|
|
273
|
+
data = _gql(gql, {"id": str(study_number)})
|
|
274
|
+
|
|
275
|
+
if "errors" in data:
|
|
276
|
+
return f"Error fetching study {study_number}: {data['errors']}"
|
|
277
|
+
|
|
278
|
+
item = data["data"]["getStudyItem"]
|
|
279
|
+
if not item:
|
|
280
|
+
return f"Study {study_number} not found."
|
|
281
|
+
|
|
282
|
+
return json.dumps(item, indent=2)[:5000]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _fetch_all_projects(client: httpx.Client) -> list[dict]:
|
|
286
|
+
"""Fetch projects where user is lead or member, returning a combined list."""
|
|
287
|
+
projects = []
|
|
288
|
+
seen = set()
|
|
289
|
+
dt_params = {"draw": 1, "start": 0, "length": 999, "search[value]": "", "search[regex]": "false"}
|
|
290
|
+
for endpoint in ("/Umbraco/Surface/Project/GetProjectsAsLead", "/Umbraco/Surface/Project/GetProjectsAsMember"):
|
|
291
|
+
r = client.get(endpoint, params=dt_params)
|
|
292
|
+
if r.status_code == 200:
|
|
293
|
+
for p in (r.json().get("data") or []):
|
|
294
|
+
pid = p.get("projectid") or p.get("projectId") or p.get("id", "")
|
|
295
|
+
if pid and pid not in seen:
|
|
296
|
+
seen.add(pid)
|
|
297
|
+
projects.append(p)
|
|
298
|
+
return projects
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@mcp.tool()
|
|
302
|
+
def list_projects() -> str:
|
|
303
|
+
"""
|
|
304
|
+
List all UKDS projects associated with your account. Requires login.
|
|
305
|
+
"""
|
|
306
|
+
with _make_client() as client:
|
|
307
|
+
if not _check_auth(client):
|
|
308
|
+
return "Not authenticated. Please run the login tool first."
|
|
309
|
+
|
|
310
|
+
projects = _fetch_all_projects(client)
|
|
311
|
+
|
|
312
|
+
if not projects:
|
|
313
|
+
return "No projects found on your account."
|
|
314
|
+
|
|
315
|
+
lines = ["Your UKDS projects:\n"]
|
|
316
|
+
for p in projects:
|
|
317
|
+
pid = p.get("projectid") or p.get("projectId") or p.get("id", "")
|
|
318
|
+
fid = p.get("friendlyid") or p.get("friendlyId") or p.get("projectFriendlyId", "")
|
|
319
|
+
title = p.get("title") or p.get("projectTitle") or p.get("name", "Untitled")
|
|
320
|
+
state = p.get("statefriendlyname") or p.get("stateName") or p.get("state", "")
|
|
321
|
+
state_str = f" [{state}]" if state else ""
|
|
322
|
+
expiry = (p.get("projectexpirydate") or "")[:10]
|
|
323
|
+
expiry_str = f" (expires {expiry})" if expiry else ""
|
|
324
|
+
lines.append(f"• [{fid}] {title}{state_str}{expiry_str}\n ID: {pid}")
|
|
325
|
+
|
|
326
|
+
return "\n".join(lines)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@mcp.tool()
|
|
330
|
+
async def add_dataset_to_project(study_number: int, project_id: str) -> str:
|
|
331
|
+
"""
|
|
332
|
+
Add a dataset (study) to one of your UKDS projects so it can be downloaded.
|
|
333
|
+
Requires login. Use list_projects to find your project IDs.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
study_number: The numeric study ID (SN), e.g. 8336
|
|
337
|
+
project_id: The project UUID to add the dataset to
|
|
338
|
+
"""
|
|
339
|
+
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout
|
|
340
|
+
import asyncio
|
|
341
|
+
|
|
342
|
+
cookies = _load_session()
|
|
343
|
+
if not cookies:
|
|
344
|
+
return "Not authenticated. Please run the login tool first."
|
|
345
|
+
|
|
346
|
+
captured_requests: list[dict] = []
|
|
347
|
+
|
|
348
|
+
async with async_playwright() as pw:
|
|
349
|
+
browser = await pw.chromium.launch(headless=True)
|
|
350
|
+
ctx = await browser.new_context()
|
|
351
|
+
|
|
352
|
+
# Inject saved session cookies
|
|
353
|
+
await ctx.add_cookies([
|
|
354
|
+
{"name": k, "value": v, "domain": "beta.ukdataservice.ac.uk", "path": "/"}
|
|
355
|
+
for k, v in cookies.items()
|
|
356
|
+
])
|
|
357
|
+
|
|
358
|
+
page = await ctx.new_page()
|
|
359
|
+
|
|
360
|
+
# Intercept the add-to-project POST so we can discover and replay it
|
|
361
|
+
async def on_request(req):
|
|
362
|
+
if req.method == "POST" and "ukdataservice.ac.uk" in req.url and "matomo" not in req.url:
|
|
363
|
+
captured_requests.append({"url": req.url, "body": req.post_data or ""})
|
|
364
|
+
|
|
365
|
+
page.on("request", on_request)
|
|
366
|
+
|
|
367
|
+
# Navigate to the dataset page
|
|
368
|
+
await page.goto(f"{BETA_BASE}/datacatalogue/studies/study?id={study_number}", timeout=30_000)
|
|
369
|
+
await asyncio.sleep(2)
|
|
370
|
+
|
|
371
|
+
# Look for and click "Add to project" or similar button
|
|
372
|
+
added = False
|
|
373
|
+
for selector in [
|
|
374
|
+
"button:has-text('Add to project')",
|
|
375
|
+
"a:has-text('Add to project')",
|
|
376
|
+
"button:has-text('Add to Project')",
|
|
377
|
+
"[data-action='add-to-project']",
|
|
378
|
+
]:
|
|
379
|
+
btn = page.locator(selector).first
|
|
380
|
+
if await btn.count() > 0:
|
|
381
|
+
await btn.click()
|
|
382
|
+
await asyncio.sleep(1)
|
|
383
|
+
added = True
|
|
384
|
+
break
|
|
385
|
+
|
|
386
|
+
if not added:
|
|
387
|
+
await browser.close()
|
|
388
|
+
return (
|
|
389
|
+
f"Could not find an 'Add to project' button on the study {study_number} page. "
|
|
390
|
+
"The dataset may already be in a project, or the page layout has changed."
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# If a project selector modal appears, choose the right project
|
|
394
|
+
for selector in [
|
|
395
|
+
f"option[value='{project_id}']",
|
|
396
|
+
f"[data-project-id='{project_id}']",
|
|
397
|
+
f"li:has-text('{project_id}')",
|
|
398
|
+
]:
|
|
399
|
+
opt = page.locator(selector).first
|
|
400
|
+
if await opt.count() > 0:
|
|
401
|
+
await opt.click()
|
|
402
|
+
await asyncio.sleep(0.5)
|
|
403
|
+
break
|
|
404
|
+
|
|
405
|
+
# Submit the form if there's a confirm button
|
|
406
|
+
for selector in ["button:has-text('Add')", "button:has-text('Confirm')", "button[type='submit']"]:
|
|
407
|
+
btn = page.locator(selector).first
|
|
408
|
+
if await btn.count() > 0:
|
|
409
|
+
await btn.click()
|
|
410
|
+
await asyncio.sleep(2)
|
|
411
|
+
break
|
|
412
|
+
|
|
413
|
+
await asyncio.sleep(2)
|
|
414
|
+
await browser.close()
|
|
415
|
+
|
|
416
|
+
if captured_requests:
|
|
417
|
+
print(f"Captured add-to-project requests: {captured_requests}")
|
|
418
|
+
|
|
419
|
+
# Verify the dataset now appears in access requests for this project
|
|
420
|
+
with _make_client() as client:
|
|
421
|
+
params: dict[str, Any] = {
|
|
422
|
+
"columns[0][data]": "studyId", "columns[0][orderable]": "true",
|
|
423
|
+
"columns[0][searchable]": "true", "columns[0][search][regex]": "false",
|
|
424
|
+
"columns[0][search][value]": "",
|
|
425
|
+
"columns[1][data]": "title", "columns[1][orderable]": "true",
|
|
426
|
+
"columns[1][searchable]": "true", "columns[1][search][regex]": "false",
|
|
427
|
+
"columns[1][search][value]": "",
|
|
428
|
+
"columns[2][data]": "projectId", "columns[2][orderable]": "true",
|
|
429
|
+
"columns[2][searchable]": "true", "columns[2][search][regex]": "false",
|
|
430
|
+
"columns[2][search][value]": project_id,
|
|
431
|
+
"draw": 1, "length": 999, "order[0][column]": 2, "order[0][dir]": "asc",
|
|
432
|
+
"search[regex]": "false", "search[value]": project_id, "start": 0,
|
|
433
|
+
}
|
|
434
|
+
r = client.get("/Umbraco/Surface/AccessRequest/GetAccessRequests", params=params)
|
|
435
|
+
if r.status_code == 200:
|
|
436
|
+
records = r.json().get("data", [])
|
|
437
|
+
for rec in records:
|
|
438
|
+
if str(rec.get("studyId", "")) == str(study_number) or str(rec.get("friendlyStudyId", "")) == str(study_number):
|
|
439
|
+
state = rec.get("stateName", "")
|
|
440
|
+
return f"Study {study_number} is now in project {rec.get('projectTitle', project_id)} with status: {state}."
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
f"Add-to-project action was attempted for study {study_number}. "
|
|
444
|
+
"Please check your UKDS account to confirm it was added successfully, "
|
|
445
|
+
"then use list_downloads to see available files."
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@mcp.tool()
|
|
450
|
+
def get_project(project_id: str) -> str:
|
|
451
|
+
"""
|
|
452
|
+
Get details of a specific UKDS project. Requires login.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
project_id: The project UUID (e.g. 'ea65ef0d-ce0a-4e06-9c22-83fdd67f57a5')
|
|
456
|
+
"""
|
|
457
|
+
with _make_client() as client:
|
|
458
|
+
r = client.get("/Umbraco/Surface/Project/GetProject", params={"projectId": project_id})
|
|
459
|
+
if r.status_code in (401, 403):
|
|
460
|
+
return "Not authenticated. Please run the login tool first."
|
|
461
|
+
if r.status_code != 200:
|
|
462
|
+
return f"Failed to fetch project (HTTP {r.status_code})."
|
|
463
|
+
return json.dumps(r.json(), indent=2)[:4000]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@mcp.tool()
|
|
467
|
+
def list_access_requests(project_id: str | None = None) -> str:
|
|
468
|
+
"""
|
|
469
|
+
List datasets you have active access to, optionally filtered by project. Requires login.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
project_id: Optional project UUID to filter by
|
|
473
|
+
"""
|
|
474
|
+
params: dict[str, Any] = {
|
|
475
|
+
"columns[0][data]": "studyId",
|
|
476
|
+
"columns[0][orderable]": "true",
|
|
477
|
+
"columns[0][searchable]": "true",
|
|
478
|
+
"columns[0][search][regex]": "false",
|
|
479
|
+
"columns[0][search][value]": "",
|
|
480
|
+
"columns[1][data]": "title",
|
|
481
|
+
"columns[1][orderable]": "true",
|
|
482
|
+
"columns[1][searchable]": "true",
|
|
483
|
+
"columns[1][search][regex]": "false",
|
|
484
|
+
"columns[1][search][value]": "",
|
|
485
|
+
"columns[2][data]": "projectId",
|
|
486
|
+
"columns[2][orderable]": "true",
|
|
487
|
+
"columns[2][searchable]": "true",
|
|
488
|
+
"columns[2][search][regex]": "false",
|
|
489
|
+
"columns[2][search][value]": "",
|
|
490
|
+
"draw": 1,
|
|
491
|
+
"length": 999,
|
|
492
|
+
"order[0][column]": 2,
|
|
493
|
+
"order[0][dir]": "asc",
|
|
494
|
+
"search[regex]": "false",
|
|
495
|
+
"search[value]": project_id or "",
|
|
496
|
+
"start": 0,
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
with _make_client() as client:
|
|
500
|
+
r = client.get("/Umbraco/Surface/AccessRequest/GetAccessRequests", params=params)
|
|
501
|
+
if r.status_code in (401, 403):
|
|
502
|
+
return "Not authenticated. Please run the login tool first."
|
|
503
|
+
if r.status_code != 200:
|
|
504
|
+
return f"Failed to list access requests (HTTP {r.status_code})."
|
|
505
|
+
|
|
506
|
+
d = r.json()
|
|
507
|
+
records = d.get("data", [])
|
|
508
|
+
|
|
509
|
+
if not records:
|
|
510
|
+
return "No active access requests found."
|
|
511
|
+
|
|
512
|
+
lines = [f"Active dataset access ({len(records)} total):\n"]
|
|
513
|
+
current_project = None
|
|
514
|
+
for rec in records:
|
|
515
|
+
proj = rec.get("projectTitle", "Unknown project")
|
|
516
|
+
pid = rec.get("projectId", "")
|
|
517
|
+
if proj != current_project:
|
|
518
|
+
current_project = proj
|
|
519
|
+
lines.append(f"\nProject: {proj} ({pid})")
|
|
520
|
+
state = rec.get("stateName", "")
|
|
521
|
+
study_id = rec.get("studyId", "")
|
|
522
|
+
title = rec.get("title", "Unknown")
|
|
523
|
+
lines.append(f" • Study {study_id}: {title} [{state}]")
|
|
524
|
+
|
|
525
|
+
return "\n".join(lines)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
@mcp.tool()
|
|
529
|
+
def list_downloads(study_number: int, project_id: str) -> str:
|
|
530
|
+
"""
|
|
531
|
+
List files available to download for a dataset within a project. Requires login.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
study_number: The numeric study ID (SN)
|
|
535
|
+
project_id: The project UUID the dataset is assigned to
|
|
536
|
+
"""
|
|
537
|
+
with _make_client() as client:
|
|
538
|
+
r = client.get(
|
|
539
|
+
"/Umbraco/Surface/Project/GetZipName",
|
|
540
|
+
params={"study": study_number},
|
|
541
|
+
)
|
|
542
|
+
if r.status_code in (401, 403):
|
|
543
|
+
return "Not authenticated. Please run the login tool first."
|
|
544
|
+
if r.status_code != 200:
|
|
545
|
+
return f"Failed to list downloads (HTTP {r.status_code})."
|
|
546
|
+
|
|
547
|
+
d = r.json()
|
|
548
|
+
files = d.get("data", d) if isinstance(d, dict) else d
|
|
549
|
+
|
|
550
|
+
if not files:
|
|
551
|
+
return f"No downloadable files found for study {study_number}."
|
|
552
|
+
|
|
553
|
+
lines = [f"Available files for study {study_number}:\n"]
|
|
554
|
+
for f in files:
|
|
555
|
+
name = f.get("fileName") or f.get("name") or f.get("label", "Unknown")
|
|
556
|
+
size = f.get("fileSize") or f.get("size", "")
|
|
557
|
+
size_str = f" ({size:,} bytes)" if isinstance(size, int) and size else f" ({size})" if size else ""
|
|
558
|
+
lines.append(f" • {name}{size_str}")
|
|
559
|
+
|
|
560
|
+
return "\n".join(lines)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@mcp.tool()
|
|
564
|
+
def download_file(
|
|
565
|
+
study_number: int,
|
|
566
|
+
file_name: str,
|
|
567
|
+
project_id: str,
|
|
568
|
+
output_path: str,
|
|
569
|
+
file_size: int = 0,
|
|
570
|
+
) -> str:
|
|
571
|
+
"""
|
|
572
|
+
Download a data file from a UKDS project to a local path. Requires login.
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
study_number: The numeric study ID (SN)
|
|
576
|
+
file_name: Exact filename as returned by list_downloads
|
|
577
|
+
project_id: The project UUID
|
|
578
|
+
output_path: Local path to save the file (e.g. '/Users/me/data/ehs2023.zip')
|
|
579
|
+
file_size: File size in bytes (optional, from list_downloads)
|
|
580
|
+
"""
|
|
581
|
+
out = Path(output_path)
|
|
582
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
583
|
+
|
|
584
|
+
cookies = _load_session()
|
|
585
|
+
if not cookies:
|
|
586
|
+
return "Not authenticated. Please run the login tool first."
|
|
587
|
+
|
|
588
|
+
jar = httpx.Cookies()
|
|
589
|
+
for k, v in cookies.items():
|
|
590
|
+
jar.set(k, v, domain="beta.ukdataservice.ac.uk")
|
|
591
|
+
|
|
592
|
+
base_headers = {"Referer": BETA_BASE + "/", "X-Requested-With": "XMLHttpRequest"}
|
|
593
|
+
|
|
594
|
+
# Try both known download endpoints
|
|
595
|
+
endpoints = [
|
|
596
|
+
("/myaccount/data/projects/download", {
|
|
597
|
+
"studyNumber": study_number,
|
|
598
|
+
"projectId": project_id,
|
|
599
|
+
"fileName": file_name,
|
|
600
|
+
}),
|
|
601
|
+
("/Umbraco/Surface/Project/GetDownload", {
|
|
602
|
+
"studyNumber": study_number,
|
|
603
|
+
"projectId": project_id,
|
|
604
|
+
"fileName": file_name,
|
|
605
|
+
**({"fileSize": file_size} if file_size else {}),
|
|
606
|
+
}),
|
|
607
|
+
]
|
|
608
|
+
|
|
609
|
+
with httpx.Client(
|
|
610
|
+
base_url=BETA_BASE,
|
|
611
|
+
cookies=jar,
|
|
612
|
+
headers=base_headers,
|
|
613
|
+
follow_redirects=True,
|
|
614
|
+
timeout=600,
|
|
615
|
+
) as client:
|
|
616
|
+
for endpoint, params in endpoints:
|
|
617
|
+
with client.stream("GET", endpoint, params=params) as r:
|
|
618
|
+
if r.status_code in (401, 403):
|
|
619
|
+
return "Not authenticated. Please run the login tool first."
|
|
620
|
+
if r.status_code == 404:
|
|
621
|
+
continue
|
|
622
|
+
if r.status_code != 200:
|
|
623
|
+
return f"Download failed (HTTP {r.status_code}) from {endpoint}."
|
|
624
|
+
|
|
625
|
+
content_type = r.headers.get("content-type", "")
|
|
626
|
+
if "text/html" in content_type or "application/json" in content_type:
|
|
627
|
+
body = r.read().decode()[:500]
|
|
628
|
+
if endpoint == endpoints[0][0]:
|
|
629
|
+
continue # try the next endpoint
|
|
630
|
+
return f"Server returned unexpected content ({content_type}): {body}"
|
|
631
|
+
|
|
632
|
+
total = 0
|
|
633
|
+
with open(out, "wb") as fh:
|
|
634
|
+
for chunk in r.iter_bytes(chunk_size=65536):
|
|
635
|
+
fh.write(chunk)
|
|
636
|
+
total += len(chunk)
|
|
637
|
+
|
|
638
|
+
return f"Downloaded {file_name} → {out} ({total:,} bytes)"
|
|
639
|
+
|
|
640
|
+
return "Download failed: no working endpoint found. Please check your login and try again."
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
if __name__ == "__main__":
|
|
644
|
+
mcp.run()
|
ukds_mcp-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ukds-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for the UK Data Service — search datasets, manage projects, download microdata
|
|
5
|
+
Requires-Python: >=3.13
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: mcp[cli]>=1.26.0
|
|
9
|
+
Requires-Dist: playwright>=1.58.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
server
|