@aborruso/ckan-mcp-server 0.4.126 → 0.4.128
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 +57 -3
- package/package.json +1 -1
package/LOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# LOG
|
|
2
2
|
|
|
3
|
+
## 2026-09-28
|
|
4
|
+
|
|
5
|
+
### v0.4.128
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
### Security: validate every redirect hop in `makeCkanRequest` (GHSA-279h-fmcr-4rwv)
|
|
10
|
+
|
|
11
|
+
`makeCkanRequest` followed up to 5 redirects via axios without re-validating the target. The SSRF-safe agent `lookup` is skipped by Node for IP literals, so a public URL 302-ing to `http://127.0.0.1/` or `169.254.169.254` was followed, and an internal endpoint answering `success: true` was returned in full (reproduced end-to-end via httpbin `redirect-to`). Fix: axios `beforeRedirect` calls `validateServerUrl` on each hop; the Workers `fetch` branch now uses `safeFetch` (`maxHops: 5`). Side effect: `CKAN_ALLOWED_DOMAINS` now applies to redirect targets too, so a portal redirecting to another host needs both listed. The public Worker (no allowlist) is unaffected for public portals.
|
|
12
|
+
|
|
13
|
+
## 2026-09-24
|
|
14
|
+
|
|
15
|
+
### v0.4.127
|
|
16
|
+
|
|
17
|
+
Ships #554: `dct:temporal` coverage in `ckan_package_show` (markdown + JSON, every period, `start_equals_issued` flag) and `CKAN_ALLOWED_DOMAINS` declared optional in `server.json` for the MCP Registry. Smoke 17/17.
|
|
18
|
+
|
|
19
|
+
### `ckan_package_show`: temporal coverage (dct:temporal)
|
|
20
|
+
|
|
21
|
+
New `readTemporalCoverage()` reads `dct:temporal` in both shapes dati.gov.it uses: root `temporal_coverage` (JSON string, e.g. Comune di Coriano) and extras `temporal_start`/`temporal_end`/`temporal_coverage` (Regione Toscana, 12,426 datasets). Rendered as **Temporal Coverage (dct:temporal)** in markdown and `temporal_coverage: [{start, end, start_equals_issued}]` in JSON, every period kept; before, JSON had nothing and markdown only the extras case. `start_equals_issued` is a factual flag for start = issued with no end: a full scan of the 17,860 datasets with `temporal_start` found a third like that, and on the origin portals (dati.toscana.it, opendata.maggioli.cloud, opendata.uniba.it, all dcatapit) `temporal_coverage` is empty in the CKAN API while their RDF export emits `dct:temporal` with `startDate` = `dct:issued`. So it is an export artefact, not a publisher entry. INPS, MIT, LaMMA weather runs and Camera di Commercio Marche carry real spans.
|
|
22
|
+
|
|
23
|
+
### server.json: declare `CKAN_ALLOWED_DOMAINS` as optional
|
|
24
|
+
|
|
25
|
+
Claude Desktop's configure dialog asked for six env vars, `CKAN_ALLOWED_DOMAINS` marked required, inferred from the README HTTP table. The registry package is stdio, where the allowlist is optional. `server.json` now declares the one variable explicitly (`isRequired: false`, placeholder). Takes effect on the next registry publish.
|
|
26
|
+
|
|
3
27
|
## 2026-09-23
|
|
4
28
|
|
|
5
29
|
### v0.4.126 - why MQA URL tests fail
|
package/dist/index.js
CHANGED
|
@@ -945,6 +945,12 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
|
|
|
945
945
|
timeout: 3e4,
|
|
946
946
|
responseType: "arraybuffer",
|
|
947
947
|
maxRedirects: 5,
|
|
948
|
+
// Re-validate every redirect hop: Node skips the agent's `lookup` for IP
|
|
949
|
+
// literals, so a public host 302-ing to e.g. 169.254.169.254 would otherwise
|
|
950
|
+
// be followed. Also enforces CKAN_ALLOWED_DOMAINS on redirect targets.
|
|
951
|
+
beforeRedirect: (redirectOptions) => {
|
|
952
|
+
validateServerUrl(redirectOptions.href);
|
|
953
|
+
},
|
|
948
954
|
// Never route through HTTP_PROXY/HTTPS_PROXY: a proxy would connect to the
|
|
949
955
|
// target itself, bypassing the SSRF-safe lookup pinned in the agents below.
|
|
950
956
|
proxy: false,
|
|
@@ -979,7 +985,7 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
|
|
|
979
985
|
const timeoutId = setTimeout(() => controller.abort(), 3e4);
|
|
980
986
|
let response;
|
|
981
987
|
try {
|
|
982
|
-
response = await
|
|
988
|
+
response = await safeFetch(fetchUrl, {
|
|
983
989
|
method: "GET",
|
|
984
990
|
signal: controller.signal,
|
|
985
991
|
headers: {
|
|
@@ -987,7 +993,7 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
|
|
|
987
993
|
"Accept-Encoding": "identity",
|
|
988
994
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
989
995
|
}
|
|
990
|
-
});
|
|
996
|
+
}, { maxHops: 5 });
|
|
991
997
|
} finally {
|
|
992
998
|
clearTimeout(timeoutId);
|
|
993
999
|
}
|
|
@@ -1517,6 +1523,42 @@ var readDcatExtra = (dataset, key) => {
|
|
|
1517
1523
|
const rootValue = dataset[key];
|
|
1518
1524
|
return typeof rootValue === "string" ? rootValue : "";
|
|
1519
1525
|
};
|
|
1526
|
+
var readExtra = (dataset, key) => {
|
|
1527
|
+
const extras = Array.isArray(dataset.extras) ? dataset.extras : [];
|
|
1528
|
+
for (const e of extras) {
|
|
1529
|
+
if (e && typeof e === "object" && e.key === key) {
|
|
1530
|
+
return e.value;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
return void 0;
|
|
1534
|
+
};
|
|
1535
|
+
var toPeriod = (item) => {
|
|
1536
|
+
if (!item || typeof item !== "object") return null;
|
|
1537
|
+
const { temporal_start, temporal_end } = item;
|
|
1538
|
+
const start = typeof temporal_start === "string" && temporal_start ? temporal_start : null;
|
|
1539
|
+
const end = typeof temporal_end === "string" && temporal_end ? temporal_end : null;
|
|
1540
|
+
return start || end ? { start, end } : null;
|
|
1541
|
+
};
|
|
1542
|
+
var parseTemporalCoverage = (raw) => {
|
|
1543
|
+
let value = raw;
|
|
1544
|
+
if (typeof value === "string") {
|
|
1545
|
+
try {
|
|
1546
|
+
value = JSON.parse(value);
|
|
1547
|
+
} catch {
|
|
1548
|
+
return [];
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
const items = Array.isArray(value) ? value : [value];
|
|
1552
|
+
return items.map(toPeriod).filter((p) => p !== null);
|
|
1553
|
+
};
|
|
1554
|
+
var readTemporalCoverage = (dataset) => {
|
|
1555
|
+
const fromRoot = parseTemporalCoverage(dataset.temporal_coverage);
|
|
1556
|
+
if (fromRoot.length > 0) return fromRoot;
|
|
1557
|
+
const fromExtra = parseTemporalCoverage(readExtra(dataset, "temporal_coverage"));
|
|
1558
|
+
if (fromExtra.length > 0) return fromExtra;
|
|
1559
|
+
return parseTemporalCoverage({ temporal_start: readExtra(dataset, "temporal_start"), temporal_end: readExtra(dataset, "temporal_end") });
|
|
1560
|
+
};
|
|
1561
|
+
var startEqualsIssued = (period, issued) => period.end === null && typeof issued === "string" && period.start !== null && period.start.slice(0, 10) === issued.slice(0, 10);
|
|
1520
1562
|
var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS, fullCoverage = false) => {
|
|
1521
1563
|
const terms = extractQueryTerms(query);
|
|
1522
1564
|
const titleText = dataset.title || dataset.name || "";
|
|
@@ -1655,6 +1697,15 @@ var formatPackageShowMarkdown = (result, serverUrl) => {
|
|
|
1655
1697
|
const accessRights = dcatField("access_rights");
|
|
1656
1698
|
if (accessRights) markdown += `- **Access Rights (dct:accessRights)**: ${sanitizeInline(accessRights)}
|
|
1657
1699
|
`;
|
|
1700
|
+
const periods = readTemporalCoverage(result);
|
|
1701
|
+
if (periods.length > 0) {
|
|
1702
|
+
const spans = periods.map((p) => {
|
|
1703
|
+
const span = `${p.start ? formatDate(p.start) : "?"} \u2192 ${p.end ? formatDate(p.end) : "open"}`;
|
|
1704
|
+
return startEqualsIssued(p, result.issued) ? `${span} (start equals issued, no end)` : span;
|
|
1705
|
+
});
|
|
1706
|
+
markdown += `- **Temporal Coverage (dct:temporal)**: ${sanitizeInline(spans.join("; "))}
|
|
1707
|
+
`;
|
|
1708
|
+
}
|
|
1658
1709
|
markdown += `
|
|
1659
1710
|
`;
|
|
1660
1711
|
if (result.organization) {
|
|
@@ -1888,6 +1939,7 @@ function compactPackageShow(result, serverUrl) {
|
|
|
1888
1939
|
holder_name: result.holder_name || null,
|
|
1889
1940
|
hvd_category: result.hvd_category || null,
|
|
1890
1941
|
applicable_legislation: result.applicable_legislation || null,
|
|
1942
|
+
temporal_coverage: readTemporalCoverage(result).map((p) => ({ ...p, start_equals_issued: startEqualsIssued(p, result.issued) })),
|
|
1891
1943
|
resources: (result.resources || []).map((r) => ({
|
|
1892
1944
|
id: r.id,
|
|
1893
1945
|
name: r.name || null,
|
|
@@ -2500,6 +2552,8 @@ Returns (JSON format):
|
|
|
2500
2552
|
author, maintainer,
|
|
2501
2553
|
frequency, language, publisher_name, holder_name,
|
|
2502
2554
|
hvd_category, applicable_legislation,
|
|
2555
|
+
temporal_coverage (array of {start, end, start_equals_issued} from dct:temporal; empty if absent;
|
|
2556
|
+
start_equals_issued=true when start = issued and no end: on dcatapit portals an export default, not a data period),
|
|
2503
2557
|
resources (id, name, format, url, size, datastore_active, created, last_modified, api_json_url),
|
|
2504
2558
|
view_url, api_json_url
|
|
2505
2559
|
|
|
@@ -5994,7 +6048,7 @@ var registerAllPrompts = (server) => {
|
|
|
5994
6048
|
function createServer() {
|
|
5995
6049
|
return new McpServer({
|
|
5996
6050
|
name: "ckan-mcp-server",
|
|
5997
|
-
version: "0.4.
|
|
6051
|
+
version: "0.4.128"
|
|
5998
6052
|
});
|
|
5999
6053
|
}
|
|
6000
6054
|
function registerAll(server) {
|