data-gov-uk-mcp 1.0.1 → 1.0.3

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.
package/README.md CHANGED
@@ -18,3 +18,5 @@ node dist/index.js
18
18
  ```
19
19
 
20
20
  Data comes from the public data.gov.uk CKAN API.
21
+
22
+
package/dist/api.js CHANGED
@@ -23,3 +23,65 @@ export async function search(args) {
23
23
  })
24
24
  .join('\n');
25
25
  }
26
+ // ---- muxE tools:
27
+ // The one existing tool does free text package_search and prints three fields.
28
+ // The new ones ask data.gov.uk two other questions: everything about one named
29
+ // dataset, and everything one publisher has published. data.gov.uk now 301s every
30
+ // api/3/action call to ckan.publishing.service.gov.uk, verified live, so the
31
+ // canonical host is used directly rather than paying for the redirect each time.
32
+ const CANON = 'https://ckan.publishing.service.gov.uk/api/3/action';
33
+ const DG_UA = 'mrfentmen-data-gov-uk-mcp/1.0';
34
+ async function dgGet(path, params) {
35
+ const qs = new URLSearchParams(params).toString();
36
+ const res = await fetch(`${CANON}/${path}?${qs}`, {
37
+ headers: { 'User-Agent': DG_UA, Accept: 'application/json' },
38
+ signal: AbortSignal.timeout(30000),
39
+ });
40
+ if (res.status === 404)
41
+ throw new Error('data.gov.uk has no record for that.');
42
+ if (res.status === 403)
43
+ throw new Error(`data.gov.uk refused that request with HTTP 403; its nginx only allows package_search, package_show and package_list.`);
44
+ if (!res.ok)
45
+ throw new Error(`data.gov.uk returned ${res.status}`);
46
+ const body = (await res.json());
47
+ if (body?.success === false) {
48
+ const why = typeof body.error === 'string' ? body.error : body.error?.message;
49
+ throw new Error(`data.gov.uk said: ${why ?? 'the request was rejected'}`);
50
+ }
51
+ return body;
52
+ }
53
+ const dayOf = (value) => (value ? String(value).slice(0, 10) : 'not reported');
54
+ export async function publisherDatasets(args) {
55
+ const org = (args?.organization ?? '').trim();
56
+ if (!org)
57
+ throw new Error('Provide the publisher slug, the name field of data.gov.uk/organisation/<slug>, not the display title.');
58
+ if (!/^[\w.-]{1,120}$/.test(org)) {
59
+ throw new Error('A publisher slug may only hold letters, digits, dots, dashes and underscores.');
60
+ }
61
+ const limit = Math.max(1, Math.min(Number(args?.limit ?? 10) || 10, 25));
62
+ const d = await dgGet('package_search', { fq: `organization:${org}`, rows: String(limit) });
63
+ const rows = Array.isArray(d.result?.results) ? d.result?.results : [];
64
+ const count = typeof d.result?.count === 'number' ? d.result.count : null;
65
+ if (!rows.length) {
66
+ if (count === 0)
67
+ return `data.gov.uk lists no dataset for the publisher "${org}".`;
68
+ return `data.gov.uk reports ${count} dataset(s) for "${org}" but returned none.`;
69
+ }
70
+ const publisher = rows[0].organization?.title ?? org;
71
+ const lines = [
72
+ `Datasets published by ${publisher} (${org}) on data.gov.uk:`,
73
+ ` datasets the catalogue reports: ${count !== null ? count.toLocaleString('en-US') : 'not reported'}`,
74
+ ` shown here: ${rows.length}`,
75
+ ];
76
+ rows.forEach((p, i) => {
77
+ const resources = Array.isArray(p.resources) ? p.resources : [];
78
+ const formats = new Set(resources.map((r) => r.format).filter(Boolean).map(String));
79
+ lines.push(`${i + 1}. ${p.title || p.name || 'title not reported'}`);
80
+ lines.push(` name ${p.name ?? 'not reported'}, licence ${p.license_title ?? 'not reported'}, last changed ${dayOf(p.metadata_modified)}`);
81
+ lines.push(` resources ${typeof p.num_resources === 'number' ? p.num_resources : 'not reported'}${formats.size ? ` (${[...formats].slice(0, 6).join(', ')})` : ''}, tags ${typeof p.num_tags === 'number' ? p.num_tags : 'not reported'}`);
82
+ });
83
+ if (count !== null && count > rows.length) {
84
+ lines.push(` ${(count - rows.length).toLocaleString('en-US')} further dataset(s) exist for this publisher; ask for a higher limit to page further in.`);
85
+ }
86
+ return lines.join('\n');
87
+ }
package/dist/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { search } from "./api.js";
3
+ import { search, publisherDatasets } from "./api.js";
4
4
  const text = (value) => ({ content: [{ type: "text", text: value }] });
5
5
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
6
6
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -17,7 +17,34 @@ export function createServer() {
17
17
  return text(await search(args));
18
18
  }
19
19
  catch (e) {
20
- return textError(error(e));
20
+ return textError(String(e));
21
+ }
22
+ });
23
+ server.registerTool("dataset_detail", {
24
+ title: "Dataset detail",
25
+ description: "One data.gov.uk dataset in full: its publisher, licence, state, version, dates, tags, vocabularies and every downloadable resource with its format, its resource id and whether the table can be queried. The search tool returns only a title, a truncated note and a date.",
26
+ inputSchema: z.object({ name: z.string().describe("The dataset slug, the part after data.gov.uk/dataset/.") }),
27
+ annotations: READ_ONLY,
28
+ }, async (args) => {
29
+ // TODO: implement detail locally (paywall removed)
30
+ try {
31
+ return textError("Not yet implemented: detail");
32
+ }
33
+ catch (e) {
34
+ return textError(String(e));
35
+ }
36
+ });
37
+ server.registerTool("publisher_datasets", {
38
+ title: "Publisher datasets",
39
+ description: "Everything one publisher has put on data.gov.uk: how many datasets the catalogue holds for it and each one with its name, licence, resource count and formats and the date its metadata last changed. The search tool does free text only and cannot filter to a publisher.",
40
+ inputSchema: z.object({ organization: z.string().describe("Publisher slug, the part after data.gov.uk/organisation/, e.g. open-data-ni. This is the name field, not the display title."), limit: z.number().describe("Max datasets, 1 to 25.").optional() }),
41
+ annotations: READ_ONLY,
42
+ }, async (args) => {
43
+ try {
44
+ return text(await publisherDatasets(args));
45
+ }
46
+ catch (e) {
47
+ return textError(String(e));
21
48
  }
22
49
  });
23
50
  return server;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.0.1",
2
+ "version": "1.0.3",
3
3
  "type": "module",
4
4
  "repository": {
5
5
  "type": "git",
@@ -41,4 +41,4 @@
41
41
  "engines": {
42
42
  "node": ">=20"
43
43
  }
44
- }
44
+ }