@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.
- package/LOG.md +20 -0
- package/dist/index.js +73 -20
- 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
|
|
755
|
+
throw new SsrfGuardError(`Invalid URL: ${serverUrl}`);
|
|
725
756
|
}
|
|
726
757
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
727
|
-
throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1098
|
+
throw new CkanTransportError(`Request timeout connecting to ${serverUrl}`, serverUrl);
|
|
1058
1099
|
} else if (axiosError.code === "ENOTFOUND") {
|
|
1059
|
-
throw new
|
|
1100
|
+
throw new CkanTransportError(`Server not found: ${serverUrl}`, serverUrl);
|
|
1060
1101
|
} else {
|
|
1061
|
-
throw new
|
|
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
|
|
1140
|
+
const original = data[key];
|
|
1141
|
+
const originalCount = original.length;
|
|
1100
1142
|
data["_truncated"] = true;
|
|
1101
1143
|
data["_original_count"] = originalCount;
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
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.
|
|
6104
|
+
version: "0.4.130"
|
|
6052
6105
|
});
|
|
6053
6106
|
}
|
|
6054
6107
|
function registerAll(server) {
|