@aborruso/ckan-mcp-server 0.4.130 → 0.4.132
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 +30 -0
- package/dist/index.js +128 -87
- package/package.json +1 -1
package/LOG.md
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
# LOG
|
|
2
2
|
|
|
3
|
+
## 2026-10-27
|
|
4
|
+
|
|
5
|
+
### v0.4.132
|
|
6
|
+
|
|
7
|
+
Ships #565: worker telemetry logs returned search results. Tests 601 passed. Smoke 16/17: the MQA case times out because data.europa.eu `/api/mqa/cache/datasets/{id}` (and its successor `/resources/{id}`) hang for every dataset id tried, while `/global`, `/countries`, `/catalogues/dati-gov-it` answer in 0.2 s; released anyway on the user's decision. The official spec (`openapi.yaml` 6.0.1-SNAPSHOT) marks `/datasets/{id}` deprecated, alias of `/resources/{id}`: switching is a separate change.
|
|
8
|
+
|
|
9
|
+
### Worker telemetry: returned search results
|
|
10
|
+
|
|
11
|
+
`ckan_package_search` and `ckan_find_relevant_datasets` now record the datasets they return (ids and names in rank order, max 20), the total count (zero included) and the start offset. The worker adds them to its log line (`result_count`, `result_start`, `result_ids`, `result_names`), and the archiver carries them, plus `fq` and `sort`, into `data/worker_events_flat.jsonl`. Goal: query → result pairs for search fine-tuning. No session id exists (stateless worker): a later `package_show` on an id in `result_ids` links to the search. Side channel keyed by the per-request `McpServer` (`src/utils/search-telemetry.ts`), so concurrent requests cannot mix. Checked with `wrangler dev` on dati.gov.it, zero-result case included.
|
|
12
|
+
|
|
13
|
+
## 2026-10-26
|
|
14
|
+
|
|
15
|
+
### Update Worker Telemetry: DuckDB pinned to v2.0.0
|
|
16
|
+
|
|
17
|
+
The workflow had failed twice a day since 2026-10-21: DuckDB v2.0.0 (released that day) renamed the CLI asset from `duckdb_cli-linux-amd64.zip` to `duckdb-cli-linux-amd64.tar.gz`, so `releases/latest/download/...zip` returned 404. Now downloads the v2.0.0 tarball explicitly (pinned, so a future rename does not break it again). Checked locally: `scripts/worker_daily_stats.sh` with v2.0.0 and v1.5.6 in UTC gives output identical to the committed `data/worker_daily_calls.jsonl`. Note: v2.0.0 reads `...Z` timestamps as `TIMESTAMPTZ`, so `::TIMESTAMP` follows the machine time zone; on the UTC runner nothing changes, on a non-UTC machine the daily counts shift.
|
|
18
|
+
|
|
19
|
+
## 2026-10-22
|
|
20
|
+
|
|
21
|
+
### v0.4.131
|
|
22
|
+
|
|
23
|
+
Ships #563: Anubis challenge retry with an honest User-Agent (#7). Tests 595 passed, smoke 17/17. Live on npm (with provenance; the registry took ~5 min to serve it after the CI publish), the MCP Registry (`isLatest`), the Worker (`/health` 0.4.131) and the GitHub release (DXT + skill attached).
|
|
24
|
+
|
|
25
|
+
### Anubis-fronted portals: retry once with an honest User-Agent (#7)
|
|
26
|
+
|
|
27
|
+
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.
|
|
28
|
+
|
|
3
29
|
## 2026-10-19
|
|
4
30
|
|
|
31
|
+
### Advisory GHSA-w3gp-3hrh-v465 published
|
|
32
|
+
|
|
33
|
+
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.
|
|
34
|
+
|
|
5
35
|
### v0.4.130
|
|
6
36
|
|
|
7
37
|
Ships #561: linear shrink in `truncateJson` (GHSA-w3gp-3hrh-v465). Tests 589 passed, smoke 17/17.
|
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));
|
|
1023
|
+
}
|
|
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);
|
|
1031
1048
|
}
|
|
1032
|
-
if (
|
|
1033
|
-
throw new
|
|
1049
|
+
if (!response.ok) {
|
|
1050
|
+
throw new CkanApiError(`CKAN API error (${response.status}): ${response.statusText}`, response.status, action, serverUrl);
|
|
1034
1051
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
throw new CkanApiError(`Response too large (${buffer.byteLength} bytes)`, void 0, action, serverUrl);
|
|
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;
|
|
@@ -1106,6 +1122,23 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
|
|
|
1106
1122
|
}
|
|
1107
1123
|
}
|
|
1108
1124
|
|
|
1125
|
+
// src/utils/search-telemetry.ts
|
|
1126
|
+
var SEARCH_TELEMETRY_MAX_RESULTS = 20;
|
|
1127
|
+
var MAX_FIELD_LENGTH = 100;
|
|
1128
|
+
var lastResults = /* @__PURE__ */ new WeakMap();
|
|
1129
|
+
function recordSearchResults(server, count, start, datasets, replyText) {
|
|
1130
|
+
const shown = datasets.filter(
|
|
1131
|
+
(d) => d.id && replyText.includes(d.id) || d.name && replyText.includes(d.name)
|
|
1132
|
+
);
|
|
1133
|
+
const kept = shown.slice(0, SEARCH_TELEMETRY_MAX_RESULTS);
|
|
1134
|
+
lastResults.set(server, {
|
|
1135
|
+
result_count: count,
|
|
1136
|
+
result_start: start,
|
|
1137
|
+
result_ids: kept.map((d) => String(d.id ?? "").slice(0, MAX_FIELD_LENGTH)),
|
|
1138
|
+
result_names: kept.map((d) => String(d.name || d.id || "").slice(0, MAX_FIELD_LENGTH))
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1109
1142
|
// src/utils/formatting.ts
|
|
1110
1143
|
function truncateText(text, limit = CHARACTER_LIMIT) {
|
|
1111
1144
|
if (text.length <= limit) {
|
|
@@ -2232,8 +2265,10 @@ Typical workflow: ckan_status_show (check locale) \u2192 ckan_package_search (qu
|
|
|
2232
2265
|
params.server_url,
|
|
2233
2266
|
effectiveQuery !== params.q ? effectiveQuery : void 0
|
|
2234
2267
|
);
|
|
2268
|
+
const text2 = truncateJson(compact);
|
|
2269
|
+
recordSearchResults(server, result.count ?? 0, effectiveStart, result.results ?? [], text2);
|
|
2235
2270
|
return {
|
|
2236
|
-
content: [{ type: "text", text:
|
|
2271
|
+
content: [{ type: "text", text: text2 }]
|
|
2237
2272
|
};
|
|
2238
2273
|
}
|
|
2239
2274
|
let hvdNote = "";
|
|
@@ -2363,8 +2398,10 @@ Note: showing top ${sorted.length} only. Use \`response_format: json\` for full
|
|
|
2363
2398
|
`;
|
|
2364
2399
|
}
|
|
2365
2400
|
}
|
|
2401
|
+
const text = truncateText(addDemoFooter(markdown));
|
|
2402
|
+
recordSearchResults(server, result.count ?? 0, effectiveStart, result.results ?? [], text);
|
|
2366
2403
|
return {
|
|
2367
|
-
content: [{ type: "text", text
|
|
2404
|
+
content: [{ type: "text", text }]
|
|
2368
2405
|
};
|
|
2369
2406
|
} catch (error) {
|
|
2370
2407
|
return {
|
|
@@ -2492,7 +2529,9 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
|
|
|
2492
2529
|
results: top
|
|
2493
2530
|
};
|
|
2494
2531
|
if (params.response_format === "json" /* JSON */) {
|
|
2495
|
-
|
|
2532
|
+
const jsonResult = jsonToolResult(payload);
|
|
2533
|
+
recordSearchResults(server, payload.total_results, 0, top, jsonResult.content[0].text);
|
|
2534
|
+
return jsonResult;
|
|
2496
2535
|
}
|
|
2497
2536
|
let markdown = `# Relevant CKAN Datasets
|
|
2498
2537
|
|
|
@@ -2568,8 +2607,10 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
|
|
|
2568
2607
|
`;
|
|
2569
2608
|
});
|
|
2570
2609
|
}
|
|
2610
|
+
const text = truncateText(addDemoFooter(markdown));
|
|
2611
|
+
recordSearchResults(server, payload.total_results, 0, top, text);
|
|
2571
2612
|
return {
|
|
2572
|
-
content: [{ type: "text", text
|
|
2613
|
+
content: [{ type: "text", text }]
|
|
2573
2614
|
};
|
|
2574
2615
|
} catch (error) {
|
|
2575
2616
|
return {
|
|
@@ -6101,7 +6142,7 @@ var registerAllPrompts = (server) => {
|
|
|
6101
6142
|
function createServer() {
|
|
6102
6143
|
return new McpServer({
|
|
6103
6144
|
name: "ckan-mcp-server",
|
|
6104
|
-
version: "0.4.
|
|
6145
|
+
version: "0.4.132"
|
|
6105
6146
|
});
|
|
6106
6147
|
}
|
|
6107
6148
|
function registerAll(server) {
|