@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.
Files changed (3) hide show
  1. package/LOG.md +24 -0
  2. package/dist/index.js +116 -88
  3. 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
- 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));
1031
1023
  }
1032
- if (error instanceof TypeError) {
1033
- throw new CkanTransportError(`Network error: ${error.message}`, serverUrl);
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
- 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);
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
- 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;
@@ -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 originalCount = data[key].length;
1156
+ const original = data[key];
1157
+ const originalCount = original.length;
1141
1158
  data["_truncated"] = true;
1142
1159
  data["_original_count"] = originalCount;
1143
- while (data[key].length > 0) {
1144
- data[key].pop();
1145
- json = JSON.stringify(data);
1146
- if (json.length <= limit) return json;
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.129"
6120
+ version: "0.4.131"
6093
6121
  });
6094
6122
  }
6095
6123
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.129",
3
+ "version": "0.4.131",
4
4
  "mcpName": "io.github.aborruso/ckan-mcp-server",
5
5
  "description": "MCP server for interacting with CKAN open data portals",
6
6
  "repository": {