@aborruso/ckan-mcp-server 0.4.112 → 0.4.113

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/LOG.md CHANGED
@@ -1,47 +1,65 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-07-31
4
+
5
+ ### v0.4.113
6
+
7
+ JSON output always parseable (issue #39).
8
+
9
+ The docs promised "always valid JSON"; the code did not deliver it. Surfaced while reviewing PR #38 (an AI contribution by `averyquinnhq`), which documented the real behaviour and contradicted issue #37 — the PR was right.
10
+
11
+ - **Live repro**: `ckan_tag_list limit=1000` on dati.gov.it → 50,046 characters, `JSON.parse` fails. Not a corner case: an ordinary call.
12
+ - **`truncateJson`**: the last-resort branch cut the serialized string (`truncateText(JSON.stringify(...))`, with an in-code comment admitting "may produce invalid JSON"). It now empties arrays progressively and, when that is not enough, degrades to a valid `{_truncated, _error}`. `SHRINKABLE_KEYS` extended (+`rows`, `datasets`, `portals`, `facets`, `fields`) with a sacrifice order: bulk rows first, `fields` last.
13
+ - **8 tools + 4 Resources** used `truncateText(JSON.stringify(...))` directly: routed through `truncateJson`. `sparql_query` appended `/* output truncated */` to cut JSON (JSON has no comments): rewritten.
14
+ - **4 tools with no cap at all**: `ckan_get_mqa_quality`, `ckan_get_mqa_quality_details` (bare JSON.stringify), `ckan_find_portals`, `ckan_status_show` (markdown).
15
+ - **A third unparseable path**: no `catch` honoured `response_format` — every error came back as prose even with `json`. New `formatError()`; errors now return `{error, _error: true}` plus `isError: true`. Zod validation errors stay textual (emitted by the SDK before the handler runs).
16
+ - **`structuredContent` stays uncapped** (65,382 characters against 50,000 of text on tag_list): capping it would drop rows from `datastore-table-ui`, which consumes it. Decision deferred, documented in `docs/DECISIONS.md`.
17
+ - 10 new tests (`truncateJson` had none), 453 passing. E2e: `tag_list` from PARSE-FAIL to parse-ok; real errors parseable in json mode.
18
+ - `CONTRIBUTING.md`: section on AI-assisted contributions (disclosure, small diffs, verifiable claims).
19
+ - Review follow-up: `addDemoFooter()` was appended to JSON output in `quality.ts`, breaking parsing on Workers — now wraps the Markdown branch only, inside `truncateText`. Added `isError: true` to the two non-dati.gov.it guards. The `truncateJson` fallback now degrades further so it always respects small limits. 454 passing.
20
+
3
21
  ## 2026-07-09
4
22
 
5
23
  ### v0.4.112
6
24
 
7
- Security — Giro 3: hardening (chiude l'ultimo gruppo di advisory in triage).
25
+ Security — Round 3: hardening (closes the last group of advisories in triage).
8
26
 
9
- - **Error reflection**: `makeCkanRequest` non incorpora più il body upstream (`JSON.stringify(decodedData)`) nell'errore verso il caller — ora messaggio generico action-scoped, dettaglio solo su stderr (troncato). `worker.ts` catch-all: rimosso `error.message` dal campo `data` JSON-RPC (log solo server-side). Chiude il canale di lettura semi-cieco della SSRF.
10
- - **postMessage UI** (`resources/datastore-table-ui.ts`): l'origin dell'host viene pinnato dalla risposta all'handshake `ui/initialize`; i messaggi con dati vengono accettati solo da quell'origin e gli outbound usano il target origin esplicito (mai `'*'`).
11
- - **Prompt-injection su org/group**: esteso il contenimento c499 ai renderer di `organization.ts` e `group.ts` — `description` in blocco untrusted (`wrapUntrusted`), newline collassati nelle liste.
12
- - 3 nuovi test (no-leak errore, fence description org/group); 443 passati. E2e: errore generico su 404 (nessun body interno), richieste normali ok. Worker build ok.
27
+ - **Error reflection**: `makeCkanRequest` no longer embeds the upstream body (`JSON.stringify(decodedData)`) in the error returned to the caller — now a generic action-scoped message, with detail on stderr only (truncated). `worker.ts` catch-all: removed `error.message` from the JSON-RPC `data` field (server-side logging only). Closes the semi-blind read channel of the SSRF.
28
+ - **postMessage UI** (`resources/datastore-table-ui.ts`): the host origin is pinned from the reply to the `ui/initialize` handshake; messages carrying data are accepted only from that origin, and outbound messages use an explicit target origin (never `'*'`).
29
+ - **Prompt injection on org/group**: extended the c499 containment to the `organization.ts` and `group.ts` renderers — `description` in an untrusted block (`wrapUntrusted`), newlines collapsed in lists.
30
+ - 3 new tests (error no-leak, org/group description fencing); 443 passing. E2e: generic error on 404 (no internal body), normal requests fine. Worker build fine.
13
31
 
14
32
  ### v0.4.111
15
33
 
16
- Security — Giro 2: tre bug distinti economici.
34
+ Security — Round 2: three distinct low-cost bugs.
17
35
 
18
- - **MQA allowlist bypass** (`isValidMqaServer`, `tools/quality.ts`): regex non ancorata sostituita da URL-parse + confronto host esatto (`dati.gov.it`/`www.dati.gov.it`). Ora i trucchi suffix (`dati.gov.it.attacker.com`) e userinfo (`dati.gov.it@attacker.com`) sono rifiutati.
19
- - **Decompression bomb / unbounded buffering** (`utils/http.ts`): cap dimensione risposta (`maxContentLength`/`maxBodyLength` su axios + check byte su `arrayBuffer` nel branch fetch, def 32MB) e cap output decompressione (`maxOutputLength` su gunzip/brotli/inflateSync + check su DecompressionStream, def 64MB). Override via `CKAN_MAX_RESPONSE_BYTES`/`CKAN_MAX_DECOMPRESSED_BYTES`. Stop a OOM/stallo da payload iper-compressi.
20
- - **Cache-key collision** (`utils/cache.ts`): `canonicalizeParams` ora produce JSON canonico tipizzato (sort ricorsivo) invece di `k=v` join con `&` non-escapati; `buildCacheKey` incornicia in `JSON.stringify([url,action,canon])`. `{q:"budget",rows:10}` e `{q:"budget&rows=10"}` non collidono più.
21
- - 2 nuovi test (+regressioni aggiornate); 440 passati. E2e: MQA host valido raggiunge data.europa.eu, bypass rifiutato, richieste normali ok. Worker build ok.
22
- - Ulteriore hardening in lavorazione nei prossimi rilasci.
36
+ - **MQA allowlist bypass** (`isValidMqaServer`, `tools/quality.ts`): unanchored regex replaced by URL parsing plus exact host comparison (`dati.gov.it`/`www.dati.gov.it`). Suffix (`dati.gov.it.attacker.com`) and userinfo (`dati.gov.it@attacker.com`) tricks are now rejected.
37
+ - **Decompression bomb / unbounded buffering** (`utils/http.ts`): response size cap (`maxContentLength`/`maxBodyLength` on axios plus a byte check on `arrayBuffer` in the fetch branch, default 32MB) and decompression output cap (`maxOutputLength` on gunzip/brotli/inflateSync plus a check on DecompressionStream, default 64MB). Override via `CKAN_MAX_RESPONSE_BYTES`/`CKAN_MAX_DECOMPRESSED_BYTES`. Stops OOM/stalls from hyper-compressed payloads.
38
+ - **Cache-key collision** (`utils/cache.ts`): `canonicalizeParams` now produces typed canonical JSON (recursive sort) instead of a `k=v` join with unescaped `&`; `buildCacheKey` frames it in `JSON.stringify([url,action,canon])`. `{q:"budget",rows:10}` and `{q:"budget&rows=10"}` no longer collide.
39
+ - 2 new tests (plus updated regressions); 440 passing. E2e: valid MQA host reaches data.europa.eu, bypass rejected, normal requests fine. Worker build fine.
40
+ - Further hardening in progress for upcoming releases.
23
41
 
24
42
  ### v0.4.110
25
43
 
26
- Security — Giro 1: cluster SSRF su path `fetch` (GHSA-vmrr, GHSA-38f8; GHSA-8hxx chiarito):
44
+ Security — Round 1: SSRF cluster on the `fetch` path (GHSA-vmrr, GHSA-38f8; GHSA-8hxx clarified):
27
45
 
28
- - **`safeFetch()` centralizzato** in `utils/http.ts`: `redirect:"manual"` + ri-validazione di ogni hop (`validateServerUrl` + `assertHostnameResolvesSafe`), bounded hops, opzione `httpsOnly`. Chiude il redirect-SSRF (es. endpoint pubblico che fa 302 verso `169.254.169.254`) senza rompere i redirect canonici legittimi. Usato da `sparql_query` (3 fetch) e dal fetch MQA metrics in `quality.ts`.
29
- - **`assertHostnameResolvesSafe` ora fail-closed**: distingue "modulo DNS assente (Workers) → no-op" da "risoluzione fallita → throw". Prima un errore DNS lasciava proseguire (fail-open / TOCTOU).
30
- - **GHSA-8hxx**: verificato che WHATWG `URL` normalizza già gli encoding IPv4 (int/hex/ottale/short) a dotted-decimal prima di `validateServerUrl` → il check esistente li copre già. Nessun codice aggiunto (la PoC dell'advisory testava la regex sulle stringhe grezze, non sull'hostname parsato). Aggiunto solo un commento e test di regressione. Residuo `::7f00:1` (IPv4-compatible IPv6) non instradabile, come ammesso dall'advisory stesso.
31
- - MQA fetch: host costante (`data.europa.eu`), quindi hardening per coerenza, non vettore reale.
32
- - 6 nuovi test (redirect→interno bloccato, redirect→non-HTTPS rifiutato, fail-closed su DNS error, IPv4-encoding bloccati). 438 passati. E2e: Wikidata via `safeFetch` ok, IP interno bloccato. Worker build ok.
33
- - Ulteriore hardening di sicurezza in lavorazione nei prossimi rilasci.
46
+ - **Centralized `safeFetch()`** in `utils/http.ts`: `redirect:"manual"` plus re-validation of every hop (`validateServerUrl` + `assertHostnameResolvesSafe`), bounded hops, `httpsOnly` option. Closes redirect-SSRF (e.g. a public endpoint 302-ing to `169.254.169.254`) without breaking legitimate canonical redirects. Used by `sparql_query` (3 fetches) and by the MQA metrics fetch in `quality.ts`.
47
+ - **`assertHostnameResolvesSafe` is now fail-closed**: it distinguishes "DNS module absent (Workers) → no-op" from "resolution failed → throw". Previously a DNS error let the request proceed (fail-open / TOCTOU).
48
+ - **GHSA-8hxx**: verified that WHATWG `URL` already normalizes IPv4 encodings (int/hex/octal/short) to dotted-decimal before `validateServerUrl` → the existing check already covers them. No code added (the advisory PoC tested the regex against raw strings, not the parsed hostname). Only a comment and regression tests. The remaining `::7f00:1` (IPv4-compatible IPv6) is not routable, as the advisory itself concedes.
49
+ - MQA fetch: constant host (`data.europa.eu`), so hardening for consistency rather than a real vector.
50
+ - 6 new tests (redirect→internal blocked, redirect→non-HTTPS rejected, fail-closed on DNS error, IPv4 encodings blocked). 438 passing. E2e: Wikidata via `safeFetch` fine, internal IP blocked. Worker build fine.
51
+ - Further security hardening in progress for upcoming releases.
34
52
 
35
53
  ### v0.4.109
36
54
 
37
- Security hardening — 3 advisories, "veleno + porta" (rischio ambientale prima dei coltelli):
55
+ Security hardening — 3 advisories, "poison and door" (environmental risk before the knives):
38
56
 
39
- - **GHSA-3369** (second-order SSRF, `ckan_list_resources`): source-portal probing è ora **opt-in** (`check_source_portal` default `false`). Prima era ON: elencare le risorse di un dataset faceva contattare host/porte presi dai dati del dataset (confused-deputy + port-scan oracle + amplificazione via `Promise.all`). Aggiunto: drop delle porte ≠80/443 in `extractSourcePortal` (usa `hostname`, non `host`), cap del fan-out a 10 probe.
40
- - **GHSA-c499** (indirect prompt injection): campi liberi del portale (`notes`, resource `description`) resi verbatim nell'output. Ora avvolti in un blocco `untrusted` delimitato con avviso (`wrapUntrusted`), fence interne neutralizzate; URL portale validati per scheme (solo http/https) e resi in inline-code (`safeUrlText`); celle tabella di `ckan_list_resources` neutralizzate (`|`, newline). Contenimento, non fix totale — documentato agli integratori.
41
- - **GHSA-v3j5** (HTTP transport esposto): bind **`127.0.0.1`** di default (era `0.0.0.0`), `enableDnsRebindingProtection` + `allowedHosts`/`allowedOrigins`. `docker-compose.yml` pubblica su `127.0.0.1:3000:3000` (+ `CKAN_HTTP_HOST=0.0.0.0` dentro il container). Nuove env: `CKAN_HTTP_HOST`, `CKAN_HTTP_ALLOWED_HOSTS`, `CKAN_HTTP_ALLOWED_ORIGINS`. Chiudere questa porta declassa l'intero cluster SSRF da "remoto" a "locale".
42
- - 4 nuovi test; 432 passati. Verificato e2e su deployment HTTP reale (bind loopback, 403 su Host non consentito, no-probe di default, notes fenced). Worker: build ok.
43
- - Docs: README (tabella env HTTP), SKILL (source-portal ora opt-in), docker/README, docker-compose.
44
- - **Non ancora fatti** (cluster SSRF fetch, MQA regex, cache, decompression bomb, postMessage UI): coltelli e hardening, in giri successivi.
57
+ - **GHSA-3369** (second-order SSRF, `ckan_list_resources`): source-portal probing is now **opt-in** (`check_source_portal` defaults to `false`). It used to be ON: listing a dataset's resources contacted hosts and ports taken from the dataset's own data (confused deputy + port-scan oracle + amplification via `Promise.all`). Added: ports other than 80/443 dropped in `extractSourcePortal` (uses `hostname`, not `host`), fan-out capped at 10 probes.
58
+ - **GHSA-c499** (indirect prompt injection): free-text portal fields (`notes`, resource `description`) were rendered verbatim in the output. They are now wrapped in a delimited `untrusted` block with a warning (`wrapUntrusted`), with inner fences neutralized; portal URLs are scheme-validated (http/https only) and rendered as inline code (`safeUrlText`); `ckan_list_resources` table cells are neutralized (`|`, newlines). Containment, not a complete fix — documented for integrators.
59
+ - **GHSA-v3j5** (exposed HTTP transport): binds to **`127.0.0.1`** by default (was `0.0.0.0`), `enableDnsRebindingProtection` plus `allowedHosts`/`allowedOrigins`. `docker-compose.yml` publishes on `127.0.0.1:3000:3000` (with `CKAN_HTTP_HOST=0.0.0.0` inside the container). New env vars: `CKAN_HTTP_HOST`, `CKAN_HTTP_ALLOWED_HOSTS`, `CKAN_HTTP_ALLOWED_ORIGINS`. Closing this door downgrades the whole SSRF cluster from "remote" to "local".
60
+ - 4 new tests; 432 passing. Verified e2e against a real HTTP deployment (loopback bind, 403 on disallowed Host, no probing by default, notes fenced). Worker build fine.
61
+ - Docs: README (HTTP env var table), SKILL (source-portal now opt-in), docker/README, docker-compose.
62
+ - **Not done yet** (fetch SSRF cluster, MQA regex, cache, decompression bomb, postMessage UI): knives and hardening, in later rounds.
45
63
 
46
64
  ## 2026-06-22
47
65
 
package/dist/index.js CHANGED
@@ -1009,26 +1009,48 @@ function truncateText(text, limit = CHARACTER_LIMIT) {
1009
1009
 
1010
1010
  ... [Response truncated at ${limit} characters]`;
1011
1011
  }
1012
+ var SHRINKABLE_KEYS = [
1013
+ "results",
1014
+ "records",
1015
+ "rows",
1016
+ "datasets",
1017
+ "resources",
1018
+ "packages",
1019
+ "organizations",
1020
+ "groups",
1021
+ "portals",
1022
+ "tags",
1023
+ "facets",
1024
+ "fields"
1025
+ ];
1012
1026
  function truncateJson(obj, limit = CHARACTER_LIMIT) {
1013
1027
  let json = JSON.stringify(obj, null, 2);
1014
1028
  if (json.length <= limit) return json;
1015
1029
  json = JSON.stringify(obj);
1016
1030
  if (json.length <= limit) return json;
1017
1031
  const data = structuredClone(obj);
1018
- const arrayKeys = ["results", "records", "resources", "packages", "organizations", "groups", "tags"];
1019
- for (const key of arrayKeys) {
1032
+ for (const key of SHRINKABLE_KEYS) {
1020
1033
  if (Array.isArray(data[key]) && data[key].length > 0) {
1021
1034
  const originalCount = data[key].length;
1022
- while (data[key].length > 1) {
1035
+ data["_truncated"] = true;
1036
+ data["_original_count"] = originalCount;
1037
+ while (data[key].length > 0) {
1023
1038
  data[key].pop();
1024
- data["_truncated"] = true;
1025
- data["_original_count"] = originalCount;
1026
1039
  json = JSON.stringify(data);
1027
1040
  if (json.length <= limit) return json;
1028
1041
  }
1029
1042
  }
1030
1043
  }
1031
- return truncateText(JSON.stringify(data), limit);
1044
+ const explained = JSON.stringify({
1045
+ _truncated: true,
1046
+ _error: `Response exceeded the ${limit} character limit and could not be reduced to fit. Narrow the query or use pagination.`
1047
+ });
1048
+ if (explained.length <= limit) return explained;
1049
+ const bare = JSON.stringify({ _truncated: true });
1050
+ return bare.length <= limit ? bare : "{}";
1051
+ }
1052
+ function formatError(message, asJson) {
1053
+ return asJson ? JSON.stringify({ error: message, _error: true }, null, 2) : message;
1032
1054
  }
1033
1055
  function wrapUntrusted(text) {
1034
1056
  const safe = String(text).replace(/```/g, "\u02BC\u02BC\u02BC");
@@ -2016,7 +2038,7 @@ Note: showing top ${sorted.length} only. Use \`response_format: json\` for full
2016
2038
  };
2017
2039
  } catch (error) {
2018
2040
  return {
2019
- content: [{ type: "text", text: formatCkanError(error, "ckan_package_search") }],
2041
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_package_search"), params.response_format === "json" /* JSON */) }],
2020
2042
  isError: true
2021
2043
  };
2022
2044
  }
@@ -2137,7 +2159,7 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2137
2159
  };
2138
2160
  if (params.response_format === "json" /* JSON */) {
2139
2161
  return {
2140
- content: [{ type: "text", text: truncateText(JSON.stringify(payload, null, 2)) }],
2162
+ content: [{ type: "text", text: truncateJson(payload) }],
2141
2163
  structuredContent: payload
2142
2164
  };
2143
2165
  }
@@ -2218,7 +2240,7 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2218
2240
  };
2219
2241
  } catch (error) {
2220
2242
  return {
2221
- content: [{ type: "text", text: formatCkanError(error, "ckan_find_relevant_datasets") }],
2243
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_find_relevant_datasets"), params.response_format === "json" /* JSON */) }],
2222
2244
  isError: true
2223
2245
  };
2224
2246
  }
@@ -2294,7 +2316,7 @@ Typical workflow: ckan_package_show \u2192 pick a resource with datastore_active
2294
2316
  };
2295
2317
  } catch (error) {
2296
2318
  return {
2297
- content: [{ type: "text", text: formatCkanError(error, "ckan_package_show") }],
2319
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_package_show"), params.response_format === "json" /* JSON */) }],
2298
2320
  isError: true
2299
2321
  };
2300
2322
  }
@@ -2396,7 +2418,7 @@ dataset's own resource URLs); set check_source_portal=true to enable it.`,
2396
2418
  resources: summary
2397
2419
  };
2398
2420
  return {
2399
- content: [{ type: "text", text: truncateText(JSON.stringify(payload, null, 2)) }],
2421
+ content: [{ type: "text", text: truncateJson(payload) }],
2400
2422
  structuredContent: payload
2401
2423
  };
2402
2424
  }
@@ -2459,7 +2481,7 @@ dataset's own resource URLs); set check_source_portal=true to enable it.`,
2459
2481
  };
2460
2482
  } catch (error) {
2461
2483
  return {
2462
- content: [{ type: "text", text: formatCkanError(error, "ckan_list_resources") }],
2484
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_list_resources"), params.response_format === "json" /* JSON */) }],
2463
2485
  isError: true
2464
2486
  };
2465
2487
  }
@@ -2746,7 +2768,7 @@ Note: organization_list returned 500; using package_search facets.
2746
2768
  };
2747
2769
  } catch (error) {
2748
2770
  return {
2749
- content: [{ type: "text", text: formatCkanError(error, "ckan_organization_list") }],
2771
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_organization_list"), params.response_format === "json" /* JSON */) }],
2750
2772
  isError: true
2751
2773
  };
2752
2774
  }
@@ -2807,7 +2829,7 @@ Typical workflow: ckan_organization_show \u2192 ckan_package_show (inspect a dat
2807
2829
  };
2808
2830
  } catch (error) {
2809
2831
  return {
2810
- content: [{ type: "text", text: formatCkanError(error, "ckan_organization_show") }],
2832
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_organization_show"), params.response_format === "json" /* JSON */) }],
2811
2833
  isError: true
2812
2834
  };
2813
2835
  }
@@ -2874,7 +2896,7 @@ Typical workflow: ckan_organization_search \u2192 ckan_organization_show (get de
2874
2896
  }))
2875
2897
  };
2876
2898
  return {
2877
- content: [{ type: "text", text: truncateText(JSON.stringify(jsonResult, null, 2)) }],
2899
+ content: [{ type: "text", text: truncateJson(jsonResult) }],
2878
2900
  structuredContent: jsonResult
2879
2901
  };
2880
2902
  }
@@ -2915,7 +2937,7 @@ Typical workflow: ckan_organization_search \u2192 ckan_organization_show (get de
2915
2937
  };
2916
2938
  } catch (error) {
2917
2939
  return {
2918
- content: [{ type: "text", text: formatCkanError(error, "ckan_organization_search") }],
2940
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_organization_search"), params.response_format === "json" /* JSON */) }],
2919
2941
  isError: true
2920
2942
  };
2921
2943
  }
@@ -3127,7 +3149,7 @@ Typical workflow: ckan_package_search \u2192 ckan_package_show (find resource_id
3127
3149
  };
3128
3150
  } catch (error) {
3129
3151
  return {
3130
- content: [{ type: "text", text: formatCkanError(error, "ckan_datastore_search") }],
3152
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_datastore_search"), params.response_format === "json" /* JSON */) }],
3131
3153
  isError: true
3132
3154
  };
3133
3155
  }
@@ -3188,7 +3210,7 @@ Security note: SQL queries are forwarded directly to the CKAN DataStore API. The
3188
3210
  };
3189
3211
  } catch (error) {
3190
3212
  return {
3191
- content: [{ type: "text", text: formatCkanError(error, "ckan_datastore_search_sql") }],
3213
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_datastore_search_sql"), params.response_format === "json" /* JSON */) }],
3192
3214
  isError: true
3193
3215
  };
3194
3216
  }
@@ -3251,7 +3273,7 @@ Typical workflow: ckan_status_show (verify server is up) \u2192 ckan_package_sea
3251
3273
  ]);
3252
3274
  const markdown = formatStatusMarkdown(result, params.server_url, hvdCount);
3253
3275
  return {
3254
- content: [{ type: "text", text: addDemoFooter(markdown) }],
3276
+ content: [{ type: "text", text: truncateText(addDemoFooter(markdown)) }],
3255
3277
  structuredContent: result
3256
3278
  };
3257
3279
  } catch (error) {
@@ -3363,7 +3385,7 @@ Typical workflow: ckan_tag_list \u2192 ckan_package_search with fq="tags:tag_nam
3363
3385
  tags
3364
3386
  };
3365
3387
  return {
3366
- content: [{ type: "text", text: truncateText(JSON.stringify(output, null, 2)) }],
3388
+ content: [{ type: "text", text: truncateJson(output) }],
3367
3389
  structuredContent: output
3368
3390
  };
3369
3391
  }
@@ -3397,7 +3419,7 @@ Typical workflow: ckan_tag_list \u2192 ckan_package_search with fq="tags:tag_nam
3397
3419
  return {
3398
3420
  content: [{
3399
3421
  type: "text",
3400
- text: `Error listing tags: ${error instanceof Error ? error.message : String(error)}`
3422
+ text: formatError(`Error listing tags: ${error instanceof Error ? error.message : String(error)}`, params.response_format === "json" /* JSON */)
3401
3423
  }],
3402
3424
  isError: true
3403
3425
  };
@@ -3640,7 +3662,7 @@ Typical workflow: ckan_group_list \u2192 ckan_group_show (inspect one) \u2192 ck
3640
3662
  };
3641
3663
  } catch (error) {
3642
3664
  return {
3643
- content: [{ type: "text", text: formatCkanError(error, "ckan_group_list") }],
3665
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_group_list"), params.response_format === "json" /* JSON */) }],
3644
3666
  isError: true
3645
3667
  };
3646
3668
  }
@@ -3698,7 +3720,7 @@ Typical workflow: ckan_group_show \u2192 ckan_package_show (inspect a dataset) \
3698
3720
  };
3699
3721
  } catch (error) {
3700
3722
  return {
3701
- content: [{ type: "text", text: formatCkanError(error, "ckan_group_show") }],
3723
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_group_show"), params.response_format === "json" /* JSON */) }],
3702
3724
  isError: true
3703
3725
  };
3704
3726
  }
@@ -3760,7 +3782,7 @@ Typical workflow: ckan_group_search \u2192 ckan_group_show (get details) \u2192
3760
3782
  }))
3761
3783
  };
3762
3784
  return {
3763
- content: [{ type: "text", text: truncateText(JSON.stringify(jsonResult, null, 2)) }],
3785
+ content: [{ type: "text", text: truncateJson(jsonResult) }],
3764
3786
  structuredContent: jsonResult
3765
3787
  };
3766
3788
  }
@@ -3800,7 +3822,7 @@ Typical workflow: ckan_group_search \u2192 ckan_group_show (get details) \u2192
3800
3822
  };
3801
3823
  } catch (error) {
3802
3824
  return {
3803
- content: [{ type: "text", text: formatCkanError(error, "ckan_group_search") }],
3825
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_group_search"), params.response_format === "json" /* JSON */) }],
3804
3826
  isError: true
3805
3827
  };
3806
3828
  }
@@ -4457,25 +4479,31 @@ function registerQualityTools(server) {
4457
4479
  return {
4458
4480
  content: [{
4459
4481
  type: "text",
4460
- text: `Error: MQA quality metrics are only available for dati.gov.it datasets. Provided server: ${server_url}
4482
+ text: formatError(
4483
+ `Error: MQA quality metrics are only available for dati.gov.it datasets. Provided server: ${server_url}
4461
4484
 
4462
- The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and only evaluates datasets from Italian open data portal.`
4463
- }]
4485
+ The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and only evaluates datasets from Italian open data portal.`,
4486
+ response_format === "json" /* JSON */
4487
+ )
4488
+ }],
4489
+ isError: true
4464
4490
  };
4465
4491
  }
4466
4492
  try {
4467
4493
  const qualityData = await getMqaQuality(server_url, dataset_id);
4468
4494
  const format = response_format || "markdown" /* MARKDOWN */;
4469
- const output = format === "json" /* JSON */ ? JSON.stringify(qualityData, null, 2) : formatQualityMarkdown(qualityData, dataset_id);
4495
+ const output = format === "json" /* JSON */ ? truncateJson(qualityData) : truncateText(addDemoFooter(formatQualityMarkdown(qualityData, dataset_id)));
4470
4496
  return {
4471
4497
  content: [{
4472
4498
  type: "text",
4473
- text: addDemoFooter(output)
4499
+ text: output
4474
4500
  }]
4475
4501
  };
4476
4502
  } catch (error) {
4503
+ const message = `Error retrieving quality metrics: ${formatCkanError(error, "ckan_get_mqa_quality")}`;
4477
4504
  return {
4478
- content: [{ type: "text", text: `Error retrieving quality metrics: ${formatCkanError(error, "ckan_get_mqa_quality")}` }]
4505
+ content: [{ type: "text", text: formatError(message, response_format === "json" /* JSON */) }],
4506
+ isError: true
4479
4507
  };
4480
4508
  }
4481
4509
  }
@@ -4502,25 +4530,31 @@ The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and on
4502
4530
  return {
4503
4531
  content: [{
4504
4532
  type: "text",
4505
- text: `Error: MQA quality details are only available for dati.gov.it datasets. Provided server: ${server_url}
4533
+ text: formatError(
4534
+ `Error: MQA quality details are only available for dati.gov.it datasets. Provided server: ${server_url}
4506
4535
 
4507
- The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and only evaluates datasets from Italian open data portal.`
4508
- }]
4536
+ The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and only evaluates datasets from Italian open data portal.`,
4537
+ response_format === "json" /* JSON */
4538
+ )
4539
+ }],
4540
+ isError: true
4509
4541
  };
4510
4542
  }
4511
4543
  try {
4512
4544
  const details = await getMqaQualityDetails(server_url, dataset_id);
4513
4545
  const format = response_format || "markdown" /* MARKDOWN */;
4514
- const output = format === "json" /* JSON */ ? JSON.stringify(details, null, 2) : formatQualityDetailsMarkdown(details, dataset_id);
4546
+ const output = format === "json" /* JSON */ ? truncateJson(details) : truncateText(addDemoFooter(formatQualityDetailsMarkdown(details, dataset_id)));
4515
4547
  return {
4516
4548
  content: [{
4517
4549
  type: "text",
4518
- text: addDemoFooter(output)
4550
+ text: output
4519
4551
  }]
4520
4552
  };
4521
4553
  } catch (error) {
4554
+ const message = `Error retrieving quality details: ${formatCkanError(error, "ckan_get_mqa_quality_details")}`;
4522
4555
  return {
4523
- content: [{ type: "text", text: `Error retrieving quality details: ${formatCkanError(error, "ckan_get_mqa_quality_details")}` }]
4556
+ content: [{ type: "text", text: formatError(message, response_format === "json" /* JSON */) }],
4557
+ isError: true
4524
4558
  };
4525
4559
  }
4526
4560
  }
@@ -4686,7 +4720,7 @@ Typical workflow: ckan_analyze_datasets \u2192 ckan_datastore_search (with known
4686
4720
  }));
4687
4721
  if (params.response_format === "json" /* JSON */) {
4688
4722
  return {
4689
- content: [{ type: "text", text: truncateText(JSON.stringify({ total: searchResult.count, datasets: analyzed }, null, 2)) }]
4723
+ content: [{ type: "text", text: truncateJson({ total: searchResult.count, datasets: analyzed }) }]
4690
4724
  };
4691
4725
  }
4692
4726
  const markdown = formatAnalyzeDatasetsMarkdown(
@@ -4700,7 +4734,7 @@ Typical workflow: ckan_analyze_datasets \u2192 ckan_datastore_search (with known
4700
4734
  };
4701
4735
  } catch (error) {
4702
4736
  return {
4703
- content: [{ type: "text", text: formatCkanError(error, "ckan_analyze_datasets") }],
4737
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_analyze_datasets"), params.response_format === "json" /* JSON */) }],
4704
4738
  isError: true
4705
4739
  };
4706
4740
  }
@@ -4779,7 +4813,7 @@ Typical workflow: ckan_catalog_stats (understand the portal) \u2192 ckan_package
4779
4813
  );
4780
4814
  if (params.response_format === "json" /* JSON */) {
4781
4815
  return {
4782
- content: [{ type: "text", text: truncateText(JSON.stringify({ total: result.count, facets: result.facets }, null, 2)) }]
4816
+ content: [{ type: "text", text: truncateJson({ total: result.count, facets: result.facets }) }]
4783
4817
  };
4784
4818
  }
4785
4819
  const markdown = formatCatalogStatsMarkdown(params.server_url, result.count, result.facets);
@@ -4788,7 +4822,7 @@ Typical workflow: ckan_catalog_stats (understand the portal) \u2192 ckan_package
4788
4822
  };
4789
4823
  } catch (error) {
4790
4824
  return {
4791
- content: [{ type: "text", text: formatCkanError(error, "ckan_catalog_stats") }],
4825
+ content: [{ type: "text", text: formatError(formatCkanError(error, "ckan_catalog_stats"), params.response_format === "json" /* JSON */) }],
4792
4826
  isError: true
4793
4827
  };
4794
4828
  }
@@ -4952,26 +4986,21 @@ Typical workflow: sparql_query (explore schema) \u2192 sparql_query (targeted qu
4952
4986
  const data = await querySparqlEndpoint(params.endpoint_url, limitedQuery);
4953
4987
  if (params.response_format === "json" /* JSON */) {
4954
4988
  const result = formatSparqlJson(data);
4955
- let text2 = JSON.stringify(result, null, 2);
4956
- if (text2.length > CHARACTER_LIMIT) {
4957
- text2 = text2.slice(0, CHARACTER_LIMIT) + "\n/* output truncated */";
4958
- }
4959
4989
  return {
4960
- content: [{ type: "text", text: text2 }],
4990
+ content: [{ type: "text", text: truncateJson(result) }],
4961
4991
  structuredContent: result
4962
4992
  };
4963
4993
  }
4964
- let text = formatSparqlMarkdown(data, params.endpoint_url);
4965
- if (text.length > CHARACTER_LIMIT) {
4966
- text = text.slice(0, CHARACTER_LIMIT) + "\n\n_Output truncated._";
4967
- }
4968
- return { content: [{ type: "text", text }] };
4994
+ return {
4995
+ content: [{ type: "text", text: truncateText(formatSparqlMarkdown(data, params.endpoint_url)) }]
4996
+ };
4969
4997
  } catch (error) {
4998
+ const message = `SPARQL query failed:
4999
+ ${error instanceof Error ? error.message : String(error)}`;
4970
5000
  return {
4971
5001
  content: [{
4972
5002
  type: "text",
4973
- text: `SPARQL query failed:
4974
- ${error instanceof Error ? error.message : String(error)}`
5003
+ text: formatError(message, params.response_format === "json" /* JSON */)
4975
5004
  }],
4976
5005
  isError: true
4977
5006
  };
@@ -5081,7 +5110,7 @@ Typical workflow: ckan_find_portals (discover portal URL) \u2192 ckan_status_sho
5081
5110
  });
5082
5111
  const markdown = formatMarkdown(results, active.length, params.limit);
5083
5112
  return {
5084
- content: [{ type: "text", text: addDemoFooter(markdown) }],
5113
+ content: [{ type: "text", text: truncateText(addDemoFooter(markdown)) }],
5085
5114
  structuredContent: { portals: results.map((p) => ({
5086
5115
  url: p.Href,
5087
5116
  title: p.SiteInfo.site_title,
@@ -5144,7 +5173,7 @@ function registerDatasetResource(server) {
5144
5173
  const result = await makeCkanRequest(serverUrl, "package_show", {
5145
5174
  id
5146
5175
  });
5147
- const content = truncateText(JSON.stringify(result, null, 2));
5176
+ const content = truncateJson(result);
5148
5177
  return {
5149
5178
  contents: [
5150
5179
  {
@@ -5188,7 +5217,7 @@ function registerResourceResource(server) {
5188
5217
  const result = await makeCkanRequest(serverUrl, "resource_show", {
5189
5218
  id
5190
5219
  });
5191
- const content = truncateText(JSON.stringify(result, null, 2));
5220
+ const content = truncateJson(result);
5192
5221
  return {
5193
5222
  contents: [
5194
5223
  {
@@ -5239,7 +5268,7 @@ function registerOrganizationResource(server) {
5239
5268
  include_datasets: false
5240
5269
  }
5241
5270
  );
5242
- const content = truncateText(JSON.stringify(result, null, 2));
5271
+ const content = truncateJson(result);
5243
5272
  return {
5244
5273
  contents: [
5245
5274
  {
@@ -5298,7 +5327,7 @@ var registerDatasetFilterResource = (server, config) => {
5298
5327
  q: "*:*",
5299
5328
  fq
5300
5329
  });
5301
- const content = truncateText(JSON.stringify(result, null, 2));
5330
+ const content = truncateJson(result);
5302
5331
  return {
5303
5332
  contents: [
5304
5333
  {