@aborruso/ckan-mcp-server 0.4.129 → 0.4.131
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 +24 -0
- package/dist/index.js +116 -88
- package/package.json +1 -1
package/LOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# LOG
|
|
2
2
|
|
|
3
|
+
## 2026-10-22
|
|
4
|
+
|
|
5
|
+
### v0.4.131
|
|
6
|
+
|
|
7
|
+
Ships #563: Anubis challenge retry with an honest User-Agent (#7). Tests 595 passed, smoke 17/17.
|
|
8
|
+
|
|
9
|
+
### Anubis-fronted portals: retry once with an honest User-Agent (#7)
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
## 2026-10-19
|
|
14
|
+
|
|
15
|
+
### Advisory GHSA-w3gp-3hrh-v465 published
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
### v0.4.130
|
|
20
|
+
|
|
21
|
+
Ships #561: linear shrink in `truncateJson` (GHSA-w3gp-3hrh-v465). Tests 589 passed, smoke 17/17.
|
|
22
|
+
|
|
23
|
+
### Security: linear shrink in `truncateJson` (GHSA-w3gp-3hrh-v465)
|
|
24
|
+
|
|
25
|
+
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).
|
|
26
|
+
|
|
3
27
|
## 2026-10-06
|
|
4
28
|
|
|
5
29
|
### v0.4.129
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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-
|
|
1025
|
-
"
|
|
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
|
-
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
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));
|
|
1031
1023
|
}
|
|
1032
|
-
|
|
1033
|
-
|
|
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);
|
|
1034
1048
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
+
if (!response.ok) {
|
|
1050
|
+
throw new CkanApiError(`CKAN API error (${response.status}): ${response.statusText}`, response.status, action, serverUrl);
|
|
1051
|
+
}
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
decodedData = await
|
|
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;
|
|
@@ -1137,14 +1153,26 @@ function truncateJson(obj, limit = CHARACTER_LIMIT) {
|
|
|
1137
1153
|
const data = structuredClone(obj);
|
|
1138
1154
|
for (const key of SHRINKABLE_KEYS) {
|
|
1139
1155
|
if (Array.isArray(data[key]) && data[key].length > 0) {
|
|
1140
|
-
const
|
|
1156
|
+
const original = data[key];
|
|
1157
|
+
const originalCount = original.length;
|
|
1141
1158
|
data["_truncated"] = true;
|
|
1142
1159
|
data["_original_count"] = originalCount;
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1160
|
+
let low = 0;
|
|
1161
|
+
let high = originalCount - 1;
|
|
1162
|
+
let best = null;
|
|
1163
|
+
while (low <= high) {
|
|
1164
|
+
const mid = Math.floor((low + high) / 2);
|
|
1165
|
+
data[key] = original.slice(0, mid);
|
|
1166
|
+
const candidate = JSON.stringify(data);
|
|
1167
|
+
if (candidate.length <= limit) {
|
|
1168
|
+
best = candidate;
|
|
1169
|
+
low = mid + 1;
|
|
1170
|
+
} else {
|
|
1171
|
+
high = mid - 1;
|
|
1172
|
+
}
|
|
1147
1173
|
}
|
|
1174
|
+
if (best !== null) return best;
|
|
1175
|
+
data[key] = [];
|
|
1148
1176
|
}
|
|
1149
1177
|
}
|
|
1150
1178
|
const explained = JSON.stringify({
|
|
@@ -6089,7 +6117,7 @@ var registerAllPrompts = (server) => {
|
|
|
6089
6117
|
function createServer() {
|
|
6090
6118
|
return new McpServer({
|
|
6091
6119
|
name: "ckan-mcp-server",
|
|
6092
|
-
version: "0.4.
|
|
6120
|
+
version: "0.4.131"
|
|
6093
6121
|
});
|
|
6094
6122
|
}
|
|
6095
6123
|
function registerAll(server) {
|