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.
@@ -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
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"
@@ -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()
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ server.py
4
+ ukds_mcp.egg-info/PKG-INFO
5
+ ukds_mcp.egg-info/SOURCES.txt
6
+ ukds_mcp.egg-info/dependency_links.txt
7
+ ukds_mcp.egg-info/entry_points.txt
8
+ ukds_mcp.egg-info/requires.txt
9
+ ukds_mcp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ukds-mcp = server:mcp.run
@@ -0,0 +1,3 @@
1
+ httpx>=0.28.1
2
+ mcp[cli]>=1.26.0
3
+ playwright>=1.58.0
@@ -0,0 +1 @@
1
+ server