@aborruso/ckan-mcp-server 0.4.128 → 0.4.130

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 +20 -0
  2. package/dist/index.js +73 -20
  3. package/package.json +1 -1
package/LOG.md CHANGED
@@ -1,7 +1,27 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-10-19
4
+
5
+ ### v0.4.130
6
+
7
+ Ships #561: linear shrink in `truncateJson` (GHSA-w3gp-3hrh-v465). Tests 589 passed, smoke 17/17.
8
+
9
+ ### Security: linear shrink in `truncateJson` (GHSA-w3gp-3hrh-v465)
10
+
11
+ Reported by @olevasseur. `truncateJson()` popped one array element at a time and re-serialized the whole object after each pop: O(n²). A portal answering `status_show` (or any tool in `response_format: json`) with a ~3 MB, 32,000-item array blocked the event loop for ~59 s. Replaced with a binary search on the largest fitting prefix: same output (543 rows kept), 17 `JSON.stringify` calls instead of 31,459, 55 ms instead of 59 s. Regression test counts serializations. Tests 589 passed; e2e on dati.gov.it `status_show` and messina DataStore JSON (1000 records → 254 kept, valid JSON).
12
+
13
+ ## 2026-10-06
14
+
15
+ ### v0.4.129
16
+
17
+ Ships #558 (by @paoValle, closes #31): error messages now tell the caller what to try next where they said nothing. A timeout, DNS failure or network error gets "Portal unreachable. Retry later, or call `ckan_status_show`" (noting its answer can be cached up to an hour), on both Node and Workers; 5xx errors point at `ckan_status_show` too; a 404 on `ckan_group_show` suggests `ckan_group_list`/`ckan_group_search`. A request refused by the SSRF guard keeps the guard's own message instead of a retry hint. Released the same day as the merge, by explicit exception to the release cadence rule. Tests 587 passed, smoke 17/17.
18
+
3
19
  ## 2026-09-28
4
20
 
21
+ ### Advisory GHSA-279h-fmcr-4rwv published
22
+
23
+ Reported by @manus-pi, accepted and published as Medium (CVSS 5.8): affected `<= 0.4.127`, patched `0.4.128`. CVE requested, not yet assigned. v0.4.128 is live on npm (with provenance), the MCP Registry and the Worker. `SECURITY.md` table updated; GHSA-x32r-mh7g-q2rf got CVE-2026-92146 in the meantime.
24
+
5
25
  ### v0.4.128
6
26
 
7
27
  Ships #556: every redirect hop in `makeCkanRequest` is re-validated (GHSA-279h-fmcr-4rwv), on both the Node/axios and Workers/fetch paths. Smoke 17/17.
package/dist/index.js CHANGED
@@ -476,7 +476,36 @@ var CkanApiError = class extends Error {
476
476
  this.serverUrl = serverUrl;
477
477
  }
478
478
  };
479
+ var CkanTransportError = class extends Error {
480
+ /** The portal the request went to, as the caller wrote it. */
481
+ serverUrl;
482
+ constructor(message, serverUrl) {
483
+ super(message);
484
+ this.name = "CkanTransportError";
485
+ this.serverUrl = serverUrl;
486
+ }
487
+ };
488
+ var SsrfGuardError = class extends Error {
489
+ constructor(message) {
490
+ super(message);
491
+ this.name = "SsrfGuardError";
492
+ }
493
+ };
494
+ function unwrapSsrfGuardError(axiosError) {
495
+ if (axiosError.cause instanceof SsrfGuardError) return axiosError.cause;
496
+ const redirected = axiosError.cause?.cause;
497
+ return redirected instanceof SsrfGuardError ? redirected : null;
498
+ }
479
499
  function formatCkanError(error, _toolName) {
500
+ if (error instanceof CkanTransportError) {
501
+ const migration2 = error.serverUrl ? getPortalMigration(error.serverUrl) : null;
502
+ if (migration2) {
503
+ return `${error.message}
504
+ \u2192 ${migration2.notice} See ${migration2.docs_url}`;
505
+ }
506
+ return `${error.message}
507
+ \u2192 Portal unreachable. Retry later, or call \`ckan_status_show\` to check portal health (its answer can come from the cache for up to an hour).`;
508
+ }
480
509
  if (!(error instanceof CkanApiError)) {
481
510
  return error instanceof Error ? error.message : String(error);
482
511
  }
@@ -496,6 +525,8 @@ function formatCkanError(error, _toolName) {
496
525
  hint = "\u2192 Use `ckan_package_search` to find a valid dataset name or ID.";
497
526
  } else if (action === "organization_show") {
498
527
  hint = "\u2192 Use `ckan_organization_list` or `ckan_organization_search` to discover valid organization names.";
528
+ } else if (action === "group_show") {
529
+ hint = "\u2192 Use `ckan_group_list` or `ckan_group_search` to discover valid group names.";
499
530
  }
500
531
  } else if (status === 400) {
501
532
  if (action === "datastore_search_sql") {
@@ -506,9 +537,9 @@ function formatCkanError(error, _toolName) {
506
537
  } else if (status === 409 || status === 422) {
507
538
  hint = "\u2192 Portal rejected the request \u2014 parameters may conflict; simplify filters and retry.";
508
539
  } else if (status === 503 || status === 502 || status === 504) {
509
- hint = "\u2192 Portal temporarily unavailable \u2014 retry in a few seconds.";
540
+ hint = "\u2192 Portal temporarily unavailable \u2014 retry in a few seconds, or call `ckan_status_show` to check portal health (cached up to an hour).";
510
541
  } else if (status === 500) {
511
- hint = "\u2192 Portal internal error \u2014 try a different portal or retry later.";
542
+ hint = "\u2192 Portal internal error \u2014 try a different portal or retry later, and call `ckan_status_show` to check portal health (cached up to an hour).";
512
543
  } else if (status === void 0) {
513
544
  hint = "\u2192 The portal may not support this action, or the endpoint is unavailable.";
514
545
  }
@@ -721,10 +752,10 @@ function validateServerUrl(serverUrl) {
721
752
  try {
722
753
  parsed = new URL(serverUrl);
723
754
  } catch {
724
- throw new Error(`Invalid URL: ${serverUrl}`);
755
+ throw new SsrfGuardError(`Invalid URL: ${serverUrl}`);
725
756
  }
726
757
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
727
- throw new Error(`Disallowed protocol "${parsed.protocol}". Only http and https are allowed.`);
758
+ throw new SsrfGuardError(`Disallowed protocol "${parsed.protocol}". Only http and https are allowed.`);
728
759
  }
729
760
  const hostname = parsed.hostname.toLowerCase();
730
761
  const BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
@@ -733,18 +764,18 @@ function validateServerUrl(serverUrl) {
733
764
  "ip6-loopback"
734
765
  ]);
735
766
  if (BLOCKED_HOSTNAMES.has(hostname)) {
736
- throw new Error(`Access to "${hostname}" is not allowed.`);
767
+ throw new SsrfGuardError(`Access to "${hostname}" is not allowed.`);
737
768
  }
738
769
  if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) && isBlockedIp(hostname)) {
739
- throw new Error(`Access to private/internal IP addresses is not allowed.`);
770
+ throw new SsrfGuardError(`Access to private/internal IP addresses is not allowed.`);
740
771
  }
741
772
  if (hostname.startsWith("[") && isBlockedIp(hostname.slice(1, -1))) {
742
- throw new Error(`Access to private/internal IPv6 addresses is not allowed.`);
773
+ throw new SsrfGuardError(`Access to private/internal IPv6 addresses is not allowed.`);
743
774
  }
744
775
  const rawAllowed = typeof process !== "undefined" ? process.env.CKAN_ALLOWED_DOMAINS ?? "" : "";
745
776
  const allowedDomains = rawAllowed.split(",").map((s) => s.trim()).filter(Boolean);
746
777
  if (allowedDomains.length > 0 && !allowedDomains.includes(hostname)) {
747
- throw new Error(`Domain "${hostname}" is not in the allowed list (CKAN_ALLOWED_DOMAINS).`);
778
+ throw new SsrfGuardError(`Domain "${hostname}" is not in the allowed list (CKAN_ALLOWED_DOMAINS).`);
748
779
  }
749
780
  }
750
781
  function assertHttpAllowlistConfigured() {
@@ -776,7 +807,7 @@ function createSsrfSafeLookup(dnsModule) {
776
807
  const list = Array.isArray(addresses) ? addresses : [addresses];
777
808
  for (const a of list) {
778
809
  if (isBlockedIp(a.address)) {
779
- callback(new Error(
810
+ callback(new SsrfGuardError(
780
811
  `Access to private/internal IP addresses is not allowed ("${hostname}" resolves to ${a.address}).`
781
812
  ));
782
813
  return;
@@ -844,11 +875,11 @@ async function assertHostnameResolvesSafe(hostname) {
844
875
  try {
845
876
  addresses = await lookup(hostname);
846
877
  } catch {
847
- throw new Error(`Cannot resolve "${hostname}" for SSRF validation (failing closed).`);
878
+ throw new SsrfGuardError(`Cannot resolve "${hostname}" for SSRF validation (failing closed).`);
848
879
  }
849
880
  for (const a of addresses) {
850
881
  if (isBlockedIp(a.address)) {
851
- throw new Error(
882
+ throw new SsrfGuardError(
852
883
  `Access to private/internal IP addresses is not allowed ("${hostname}" resolves to ${a.address}).`
853
884
  );
854
885
  }
@@ -994,6 +1025,14 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
994
1025
  "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
995
1026
  }
996
1027
  }, { maxHops: 5 });
1028
+ } catch (error) {
1029
+ if (controller.signal.aborted) {
1030
+ throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
1031
+ }
1032
+ if (error instanceof TypeError) {
1033
+ throw new CkanTransportError(`Network error: ${error.message}`, serverUrl);
1034
+ }
1035
+ throw error;
997
1036
  } finally {
998
1037
  clearTimeout(timeoutId);
999
1038
  }
@@ -1048,17 +1087,19 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
1048
1087
  if (error instanceof CkanApiError) throw error;
1049
1088
  if (axios.isAxiosError(error)) {
1050
1089
  const axiosError = error;
1090
+ const guardError = unwrapSsrfGuardError(axiosError);
1091
+ if (guardError) throw guardError;
1051
1092
  if (axiosError.response) {
1052
1093
  const status = axiosError.response.status;
1053
1094
  const data = axiosError.response.data;
1054
1095
  const errorMsg = data?.error?.message || data?.error || "Unknown error";
1055
1096
  throw new CkanApiError(`CKAN API error (${status}): ${errorMsg}`, status, action, serverUrl);
1056
1097
  } else if (axiosError.code === "ECONNABORTED") {
1057
- throw new Error(`Request timeout connecting to ${serverUrl}`);
1098
+ throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
1058
1099
  } else if (axiosError.code === "ENOTFOUND") {
1059
- throw new Error(`Server not found: ${serverUrl}`);
1100
+ throw new CkanTransportError(`Server not found: ${serverUrl}`, serverUrl);
1060
1101
  } else {
1061
- throw new Error(`Network error: ${axiosError.message}`);
1102
+ throw new CkanTransportError(`Network error: ${axiosError.message}`, serverUrl);
1062
1103
  }
1063
1104
  }
1064
1105
  throw error;
@@ -1096,14 +1137,26 @@ function truncateJson(obj, limit = CHARACTER_LIMIT) {
1096
1137
  const data = structuredClone(obj);
1097
1138
  for (const key of SHRINKABLE_KEYS) {
1098
1139
  if (Array.isArray(data[key]) && data[key].length > 0) {
1099
- const originalCount = data[key].length;
1140
+ const original = data[key];
1141
+ const originalCount = original.length;
1100
1142
  data["_truncated"] = true;
1101
1143
  data["_original_count"] = originalCount;
1102
- while (data[key].length > 0) {
1103
- data[key].pop();
1104
- json = JSON.stringify(data);
1105
- if (json.length <= limit) return json;
1144
+ let low = 0;
1145
+ let high = originalCount - 1;
1146
+ let best = null;
1147
+ while (low <= high) {
1148
+ const mid = Math.floor((low + high) / 2);
1149
+ data[key] = original.slice(0, mid);
1150
+ const candidate = JSON.stringify(data);
1151
+ if (candidate.length <= limit) {
1152
+ best = candidate;
1153
+ low = mid + 1;
1154
+ } else {
1155
+ high = mid - 1;
1156
+ }
1106
1157
  }
1158
+ if (best !== null) return best;
1159
+ data[key] = [];
1107
1160
  }
1108
1161
  }
1109
1162
  const explained = JSON.stringify({
@@ -6048,7 +6101,7 @@ var registerAllPrompts = (server) => {
6048
6101
  function createServer() {
6049
6102
  return new McpServer({
6050
6103
  name: "ckan-mcp-server",
6051
- version: "0.4.128"
6104
+ version: "0.4.130"
6052
6105
  });
6053
6106
  }
6054
6107
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.128",
3
+ "version": "0.4.130",
4
4
  "mcpName": "io.github.aborruso/ckan-mcp-server",
5
5
  "description": "MCP server for interacting with CKAN open data portals",
6
6
  "repository": {