@aborruso/ckan-mcp-server 0.4.130 → 0.4.132

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.
Files changed (3) hide show
  1. package/LOG.md +30 -0
  2. package/dist/index.js +128 -87
  3. package/package.json +1 -1
package/LOG.md CHANGED
@@ -1,7 +1,37 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-10-27
4
+
5
+ ### v0.4.132
6
+
7
+ Ships #565: worker telemetry logs returned search results. Tests 601 passed. Smoke 16/17: the MQA case times out because data.europa.eu `/api/mqa/cache/datasets/{id}` (and its successor `/resources/{id}`) hang for every dataset id tried, while `/global`, `/countries`, `/catalogues/dati-gov-it` answer in 0.2 s; released anyway on the user's decision. The official spec (`openapi.yaml` 6.0.1-SNAPSHOT) marks `/datasets/{id}` deprecated, alias of `/resources/{id}`: switching is a separate change.
8
+
9
+ ### Worker telemetry: returned search results
10
+
11
+ `ckan_package_search` and `ckan_find_relevant_datasets` now record the datasets they return (ids and names in rank order, max 20), the total count (zero included) and the start offset. The worker adds them to its log line (`result_count`, `result_start`, `result_ids`, `result_names`), and the archiver carries them, plus `fq` and `sort`, into `data/worker_events_flat.jsonl`. Goal: query → result pairs for search fine-tuning. No session id exists (stateless worker): a later `package_show` on an id in `result_ids` links to the search. Side channel keyed by the per-request `McpServer` (`src/utils/search-telemetry.ts`), so concurrent requests cannot mix. Checked with `wrangler dev` on dati.gov.it, zero-result case included.
12
+
13
+ ## 2026-10-26
14
+
15
+ ### Update Worker Telemetry: DuckDB pinned to v2.0.0
16
+
17
+ The workflow had failed twice a day since 2026-10-21: DuckDB v2.0.0 (released that day) renamed the CLI asset from `duckdb_cli-linux-amd64.zip` to `duckdb-cli-linux-amd64.tar.gz`, so `releases/latest/download/...zip` returned 404. Now downloads the v2.0.0 tarball explicitly (pinned, so a future rename does not break it again). Checked locally: `scripts/worker_daily_stats.sh` with v2.0.0 and v1.5.6 in UTC gives output identical to the committed `data/worker_daily_calls.jsonl`. Note: v2.0.0 reads `...Z` timestamps as `TIMESTAMPTZ`, so `::TIMESTAMP` follows the machine time zone; on the UTC runner nothing changes, on a non-UTC machine the daily counts shift.
18
+
19
+ ## 2026-10-22
20
+
21
+ ### v0.4.131
22
+
23
+ Ships #563: Anubis challenge retry with an honest User-Agent (#7). Tests 595 passed, smoke 17/17. Live on npm (with provenance; the registry took ~5 min to serve it after the CI publish), the MCP Registry (`isLatest`), the Worker (`/health` 0.4.131) and the GitHub release (DXT + skill attached).
24
+
25
+ ### Anubis-fronted portals: retry once with an honest User-Agent (#7)
26
+
27
+ Reported by @umlwbin: our Chrome-impersonating headers (needed by dati.anticorruzione.it's WAF) trip Anubis anti-bot gates, which answer with an HTML proof-of-work page that axios cannot solve. Anubis challenges any UA containing `Mozilla`, including `Mozilla/5.0 (compatible; ...)`, so no fixed UA serves both portals. `makeCkanRequest` now detects the challenge page (`anubis_challenge` marker, `isAnubisChallenge`) and retries once with `CKAN-MCP-Server/1.0` and no `Sec-CH-UA*` headers, on both the axios and fetch/Workers paths. The retry acquires its own rate-limit token (Greptile review). +6 tests (fail without the fix). Tests 595 passed; e2e `package_search` OK on canwin-datahub.ad.umanitoba.ca/data (48,947), dati.anticorruzione.it (70), dati.gov.it.
28
+
3
29
  ## 2026-10-19
4
30
 
31
+ ### Advisory GHSA-w3gp-3hrh-v465 published
32
+
33
+ Reported by @olevasseur, accepted and published as Medium (CVSS 6.5): affected `>= 0.4.114, <= 0.4.129`, patched `0.4.130`. CVE requested, not yet assigned. v0.4.130 is live on npm (with provenance), the MCP Registry and the Worker. `SECURITY.md` table updated.
34
+
5
35
  ### v0.4.130
6
36
 
7
37
  Ships #561: linear shrink in `truncateJson` (GHSA-w3gp-3hrh-v465). Tests 589 passed, smoke 17/17.
package/dist/index.js CHANGED
@@ -932,6 +932,11 @@ function auditLog(serverUrl, action, params, cacheHit) {
932
932
  } catch {
933
933
  }
934
934
  }
935
+ var BROWSER_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
936
+ var HONEST_USER_AGENT = "CKAN-MCP-Server/1.0";
937
+ function isAnubisChallenge(body) {
938
+ return typeof body === "string" && body.includes("anubis_challenge");
939
+ }
935
940
  async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
936
941
  const isNode = typeof process !== "undefined" && !!process.versions?.node;
937
942
  validateServerUrl(serverUrl);
@@ -963,95 +968,106 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
963
968
  _lastCacheHit = false;
964
969
  const rateLimitConfig = getRateLimitConfig();
965
970
  const rateLimitEnabled = rateLimitConfig.enabled && opts.rateLimit !== false;
966
- if (rateLimitEnabled) {
967
- const hostname = new URL(resolvedServerUrl).hostname;
968
- await getRateLimiter().acquire(hostname);
969
- }
971
+ const acquireRateToken = async () => {
972
+ if (rateLimitEnabled) {
973
+ const hostname = new URL(resolvedServerUrl).hostname;
974
+ await getRateLimiter().acquire(hostname);
975
+ }
976
+ };
977
+ await acquireRateToken();
970
978
  try {
971
- let decodedData;
972
- if (isNode) {
973
- const safeAgents = await getSafeAgents();
974
- const response = await axios.get(url, {
975
- params,
976
- timeout: 3e4,
977
- responseType: "arraybuffer",
978
- maxRedirects: 5,
979
- // Re-validate every redirect hop: Node skips the agent's `lookup` for IP
980
- // literals, so a public host 302-ing to e.g. 169.254.169.254 would otherwise
981
- // be followed. Also enforces CKAN_ALLOWED_DOMAINS on redirect targets.
982
- beforeRedirect: (redirectOptions) => {
983
- validateServerUrl(redirectOptions.href);
984
- },
985
- // Never route through HTTP_PROXY/HTTPS_PROXY: a proxy would connect to the
986
- // target itself, bypassing the SSRF-safe lookup pinned in the agents below.
987
- proxy: false,
988
- maxContentLength: MAX_RESPONSE_BYTES,
989
- maxBodyLength: MAX_RESPONSE_BYTES,
990
- ...safeAgents ? { httpAgent: safeAgents.httpAgent, httpsAgent: safeAgents.httpsAgent } : {},
991
- headers: {
992
- Accept: "application/json, text/plain, */*",
993
- "Accept-Language": "en-US,en;q=0.9,it;q=0.8",
994
- "Accept-Encoding": "gzip, deflate, br",
995
- Connection: "keep-alive",
996
- "Sec-CH-UA": '"Chromium";v="120", "Not?A_Brand";v="24", "Google Chrome";v="120"',
997
- "Sec-CH-UA-Mobile": "?0",
998
- "Sec-CH-UA-Platform": '"Linux"',
999
- "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
1000
- }
1001
- });
1002
- decodedData = await decodePossiblyCompressed(
1003
- response.data,
1004
- response.headers
1005
- );
1006
- } else {
1007
- const searchParams = new URLSearchParams();
1008
- for (const [key, value] of Object.entries(params)) {
1009
- if (value === void 0 || value === null) {
1010
- continue;
1011
- }
1012
- searchParams.set(key, String(value));
1013
- }
1014
- const fetchUrl = searchParams.toString() ? `${url}?${searchParams.toString()}` : url;
1015
- const controller = new AbortController();
1016
- const timeoutId = setTimeout(() => controller.abort(), 3e4);
1017
- let response;
1018
- try {
1019
- response = await safeFetch(fetchUrl, {
1020
- method: "GET",
1021
- signal: controller.signal,
979
+ const fetchDecoded = async (honest) => {
980
+ if (isNode) {
981
+ const safeAgents = await getSafeAgents();
982
+ const response = await axios.get(url, {
983
+ params,
984
+ timeout: 3e4,
985
+ responseType: "arraybuffer",
986
+ maxRedirects: 5,
987
+ // Re-validate every redirect hop: Node skips the agent's `lookup` for IP
988
+ // literals, so a public host 302-ing to e.g. 169.254.169.254 would otherwise
989
+ // be followed. Also enforces CKAN_ALLOWED_DOMAINS on redirect targets.
990
+ beforeRedirect: (redirectOptions) => {
991
+ validateServerUrl(redirectOptions.href);
992
+ },
993
+ // Never route through HTTP_PROXY/HTTPS_PROXY: a proxy would connect to the
994
+ // target itself, bypassing the SSRF-safe lookup pinned in the agents below.
995
+ proxy: false,
996
+ maxContentLength: MAX_RESPONSE_BYTES,
997
+ maxBodyLength: MAX_RESPONSE_BYTES,
998
+ ...safeAgents ? { httpAgent: safeAgents.httpAgent, httpsAgent: safeAgents.httpsAgent } : {},
1022
999
  headers: {
1023
1000
  Accept: "application/json, text/plain, */*",
1024
- "Accept-Encoding": "identity",
1025
- "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
1001
+ "Accept-Language": "en-US,en;q=0.9,it;q=0.8",
1002
+ "Accept-Encoding": "gzip, deflate, br",
1003
+ Connection: "keep-alive",
1004
+ ...honest ? {} : {
1005
+ "Sec-CH-UA": '"Chromium";v="120", "Not?A_Brand";v="24", "Google Chrome";v="120"',
1006
+ "Sec-CH-UA-Mobile": "?0",
1007
+ "Sec-CH-UA-Platform": '"Linux"'
1008
+ },
1009
+ "User-Agent": honest ? HONEST_USER_AGENT : BROWSER_USER_AGENT
1026
1010
  }
1027
- }, { maxHops: 5 });
1028
- } catch (error) {
1029
- if (controller.signal.aborted) {
1030
- throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
1011
+ });
1012
+ return decodePossiblyCompressed(
1013
+ response.data,
1014
+ response.headers
1015
+ );
1016
+ } else {
1017
+ const searchParams = new URLSearchParams();
1018
+ for (const [key, value] of Object.entries(params)) {
1019
+ if (value === void 0 || value === null) {
1020
+ continue;
1021
+ }
1022
+ searchParams.set(key, String(value));
1023
+ }
1024
+ const fetchUrl = searchParams.toString() ? `${url}?${searchParams.toString()}` : url;
1025
+ const controller = new AbortController();
1026
+ const timeoutId = setTimeout(() => controller.abort(), 3e4);
1027
+ let response;
1028
+ try {
1029
+ response = await safeFetch(fetchUrl, {
1030
+ method: "GET",
1031
+ signal: controller.signal,
1032
+ headers: {
1033
+ Accept: "application/json, text/plain, */*",
1034
+ "Accept-Encoding": "identity",
1035
+ "User-Agent": honest ? HONEST_USER_AGENT : BROWSER_USER_AGENT
1036
+ }
1037
+ }, { maxHops: 5 });
1038
+ } catch (error) {
1039
+ if (controller.signal.aborted) {
1040
+ throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
1041
+ }
1042
+ if (error instanceof TypeError) {
1043
+ throw new CkanTransportError(`Network error: ${error.message}`, serverUrl);
1044
+ }
1045
+ throw error;
1046
+ } finally {
1047
+ clearTimeout(timeoutId);
1031
1048
  }
1032
- if (error instanceof TypeError) {
1033
- throw new CkanTransportError(`Network error: ${error.message}`, serverUrl);
1049
+ if (!response.ok) {
1050
+ throw new CkanApiError(`CKAN API error (${response.status}): ${response.statusText}`, response.status, action, serverUrl);
1034
1051
  }
1035
- throw error;
1036
- } finally {
1037
- clearTimeout(timeoutId);
1038
- }
1039
- if (!response.ok) {
1040
- throw new CkanApiError(`CKAN API error (${response.status}): ${response.statusText}`, response.status, action, serverUrl);
1041
- }
1042
- const declaredLength = Number(response.headers.get("content-length"));
1043
- if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
1044
- throw new CkanApiError(`Response too large (${declaredLength} bytes)`, void 0, action, serverUrl);
1045
- }
1046
- const buffer = await response.arrayBuffer();
1047
- if (buffer.byteLength > MAX_RESPONSE_BYTES) {
1048
- throw new CkanApiError(`Response too large (${buffer.byteLength} bytes)`, void 0, action, serverUrl);
1052
+ const declaredLength = Number(response.headers.get("content-length"));
1053
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
1054
+ throw new CkanApiError(`Response too large (${declaredLength} bytes)`, void 0, action, serverUrl);
1055
+ }
1056
+ const buffer = await response.arrayBuffer();
1057
+ if (buffer.byteLength > MAX_RESPONSE_BYTES) {
1058
+ throw new CkanApiError(`Response too large (${buffer.byteLength} bytes)`, void 0, action, serverUrl);
1059
+ }
1060
+ const headers = {};
1061
+ response.headers.forEach((headerValue, headerKey) => {
1062
+ headers[headerKey] = headerValue;
1063
+ });
1064
+ return decodePossiblyCompressed(buffer, headers);
1049
1065
  }
1050
- const headers = {};
1051
- response.headers.forEach((headerValue, headerKey) => {
1052
- headers[headerKey] = headerValue;
1053
- });
1054
- decodedData = await decodePossiblyCompressed(buffer, headers);
1066
+ };
1067
+ let decodedData = await fetchDecoded(false);
1068
+ if (isAnubisChallenge(decodedData)) {
1069
+ await acquireRateToken();
1070
+ decodedData = await fetchDecoded(true);
1055
1071
  }
1056
1072
  if (decodedData && decodedData.success === true) {
1057
1073
  const result = decodedData.result;
@@ -1106,6 +1122,23 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
1106
1122
  }
1107
1123
  }
1108
1124
 
1125
+ // src/utils/search-telemetry.ts
1126
+ var SEARCH_TELEMETRY_MAX_RESULTS = 20;
1127
+ var MAX_FIELD_LENGTH = 100;
1128
+ var lastResults = /* @__PURE__ */ new WeakMap();
1129
+ function recordSearchResults(server, count, start, datasets, replyText) {
1130
+ const shown = datasets.filter(
1131
+ (d) => d.id && replyText.includes(d.id) || d.name && replyText.includes(d.name)
1132
+ );
1133
+ const kept = shown.slice(0, SEARCH_TELEMETRY_MAX_RESULTS);
1134
+ lastResults.set(server, {
1135
+ result_count: count,
1136
+ result_start: start,
1137
+ result_ids: kept.map((d) => String(d.id ?? "").slice(0, MAX_FIELD_LENGTH)),
1138
+ result_names: kept.map((d) => String(d.name || d.id || "").slice(0, MAX_FIELD_LENGTH))
1139
+ });
1140
+ }
1141
+
1109
1142
  // src/utils/formatting.ts
1110
1143
  function truncateText(text, limit = CHARACTER_LIMIT) {
1111
1144
  if (text.length <= limit) {
@@ -2232,8 +2265,10 @@ Typical workflow: ckan_status_show (check locale) \u2192 ckan_package_search (qu
2232
2265
  params.server_url,
2233
2266
  effectiveQuery !== params.q ? effectiveQuery : void 0
2234
2267
  );
2268
+ const text2 = truncateJson(compact);
2269
+ recordSearchResults(server, result.count ?? 0, effectiveStart, result.results ?? [], text2);
2235
2270
  return {
2236
- content: [{ type: "text", text: truncateJson(compact) }]
2271
+ content: [{ type: "text", text: text2 }]
2237
2272
  };
2238
2273
  }
2239
2274
  let hvdNote = "";
@@ -2363,8 +2398,10 @@ Note: showing top ${sorted.length} only. Use \`response_format: json\` for full
2363
2398
  `;
2364
2399
  }
2365
2400
  }
2401
+ const text = truncateText(addDemoFooter(markdown));
2402
+ recordSearchResults(server, result.count ?? 0, effectiveStart, result.results ?? [], text);
2366
2403
  return {
2367
- content: [{ type: "text", text: truncateText(addDemoFooter(markdown)) }]
2404
+ content: [{ type: "text", text }]
2368
2405
  };
2369
2406
  } catch (error) {
2370
2407
  return {
@@ -2492,7 +2529,9 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2492
2529
  results: top
2493
2530
  };
2494
2531
  if (params.response_format === "json" /* JSON */) {
2495
- return jsonToolResult(payload);
2532
+ const jsonResult = jsonToolResult(payload);
2533
+ recordSearchResults(server, payload.total_results, 0, top, jsonResult.content[0].text);
2534
+ return jsonResult;
2496
2535
  }
2497
2536
  let markdown = `# Relevant CKAN Datasets
2498
2537
 
@@ -2568,8 +2607,10 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2568
2607
  `;
2569
2608
  });
2570
2609
  }
2610
+ const text = truncateText(addDemoFooter(markdown));
2611
+ recordSearchResults(server, payload.total_results, 0, top, text);
2571
2612
  return {
2572
- content: [{ type: "text", text: truncateText(addDemoFooter(markdown)) }]
2613
+ content: [{ type: "text", text }]
2573
2614
  };
2574
2615
  } catch (error) {
2575
2616
  return {
@@ -6101,7 +6142,7 @@ var registerAllPrompts = (server) => {
6101
6142
  function createServer() {
6102
6143
  return new McpServer({
6103
6144
  name: "ckan-mcp-server",
6104
- version: "0.4.130"
6145
+ version: "0.4.132"
6105
6146
  });
6106
6147
  }
6107
6148
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.130",
3
+ "version": "0.4.132",
4
4
  "mcpName": "io.github.aborruso/ckan-mcp-server",
5
5
  "description": "MCP server for interacting with CKAN open data portals",
6
6
  "repository": {