@aborruso/ckan-mcp-server 0.4.125 → 0.4.127
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 +113 -6
- package/package.json +1 -1
package/LOG.md
CHANGED
|
@@ -1,7 +1,31 @@
|
|
|
1
1
|
# LOG
|
|
2
2
|
|
|
3
|
+
## 2026-09-24
|
|
4
|
+
|
|
5
|
+
### v0.4.127
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
### `ckan_package_show`: temporal coverage (dct:temporal)
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
### server.json: declare `CKAN_ALLOWED_DOMAINS` as optional
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
3
17
|
## 2026-09-23
|
|
4
18
|
|
|
19
|
+
### v0.4.126 - why MQA URL tests fail
|
|
20
|
+
|
|
21
|
+
Ships #552. For a caller: `ckan_get_mqa_quality_details` now says why URL tests fail (`HTTP test: 1100 timeout ×21, no status recorded ×3`). For the project: `npm run smoke` checks the MQA tool live against data.europa.eu, so a format change there fails the release gate.
|
|
22
|
+
|
|
23
|
+
### MQA: smoke gate and HTTP status of failing URL tests
|
|
24
|
+
|
|
25
|
+
- `npm run smoke` gains a live MQA case (Roma `czrm-…-2023`: `methodology` v2, `maxScore` 7.5, 0 ≤ `score` ≤ 7.5) and a generic `field_max`. v0.4.124 shipped broken MQA tools with green tests because the tests read stored fixtures; the gate now asks data.europa.eu. Negative check: expecting `v1` fails the case.
|
|
26
|
+
- `ckan_get_mqa_quality_details` says why URL tests fail: `HTTP test: 1100 timeout ×21, no status recorded ×3`. Codes come from the metrics graph (`http:statusCodeValue` in v2, `dqv:value` in v1; ≥1000 are piveau's own, 1100 = timeout), latest per distribution, 2xx excluded. Fetched only by the details tool and only when a URL test fails (the graph is ~570 KB); best effort, a failed fetch leaves the details intact.
|
|
27
|
+
- Bug caught by the tests: boolean result nodes share the metric id, and `Number(false)` read as status code 0.
|
|
28
|
+
|
|
5
29
|
### v0.4.125 - MQA scores on the new data.europa.eu methodology
|
|
6
30
|
|
|
7
31
|
Ships #549 (closes #548). For a caller: `ckan_get_mqa_quality` and `ckan_get_mqa_quality_details` report the MQA v2 score (0-7.5, band, failing metrics by gain) instead of printing v2 numbers on the old 405 scale or failing with "identifier not aligned"; datasets not yet re-evaluated get the previous-methodology score, labelled. The MQA tools work again on the Cloudflare Worker. **BREAKING** for JSON consumers of the two MQA tools: compact shape with `methodology`, `metricsVersion`, `score`, `maxScore`.
|
package/dist/index.js
CHANGED
|
@@ -1517,6 +1517,42 @@ var readDcatExtra = (dataset, key) => {
|
|
|
1517
1517
|
const rootValue = dataset[key];
|
|
1518
1518
|
return typeof rootValue === "string" ? rootValue : "";
|
|
1519
1519
|
};
|
|
1520
|
+
var readExtra = (dataset, key) => {
|
|
1521
|
+
const extras = Array.isArray(dataset.extras) ? dataset.extras : [];
|
|
1522
|
+
for (const e of extras) {
|
|
1523
|
+
if (e && typeof e === "object" && e.key === key) {
|
|
1524
|
+
return e.value;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
return void 0;
|
|
1528
|
+
};
|
|
1529
|
+
var toPeriod = (item) => {
|
|
1530
|
+
if (!item || typeof item !== "object") return null;
|
|
1531
|
+
const { temporal_start, temporal_end } = item;
|
|
1532
|
+
const start = typeof temporal_start === "string" && temporal_start ? temporal_start : null;
|
|
1533
|
+
const end = typeof temporal_end === "string" && temporal_end ? temporal_end : null;
|
|
1534
|
+
return start || end ? { start, end } : null;
|
|
1535
|
+
};
|
|
1536
|
+
var parseTemporalCoverage = (raw) => {
|
|
1537
|
+
let value = raw;
|
|
1538
|
+
if (typeof value === "string") {
|
|
1539
|
+
try {
|
|
1540
|
+
value = JSON.parse(value);
|
|
1541
|
+
} catch {
|
|
1542
|
+
return [];
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
const items = Array.isArray(value) ? value : [value];
|
|
1546
|
+
return items.map(toPeriod).filter((p) => p !== null);
|
|
1547
|
+
};
|
|
1548
|
+
var readTemporalCoverage = (dataset) => {
|
|
1549
|
+
const fromRoot = parseTemporalCoverage(dataset.temporal_coverage);
|
|
1550
|
+
if (fromRoot.length > 0) return fromRoot;
|
|
1551
|
+
const fromExtra = parseTemporalCoverage(readExtra(dataset, "temporal_coverage"));
|
|
1552
|
+
if (fromExtra.length > 0) return fromExtra;
|
|
1553
|
+
return parseTemporalCoverage({ temporal_start: readExtra(dataset, "temporal_start"), temporal_end: readExtra(dataset, "temporal_end") });
|
|
1554
|
+
};
|
|
1555
|
+
var startEqualsIssued = (period, issued) => period.end === null && typeof issued === "string" && period.start !== null && period.start.slice(0, 10) === issued.slice(0, 10);
|
|
1520
1556
|
var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS, fullCoverage = false) => {
|
|
1521
1557
|
const terms = extractQueryTerms(query);
|
|
1522
1558
|
const titleText = dataset.title || dataset.name || "";
|
|
@@ -1655,6 +1691,15 @@ var formatPackageShowMarkdown = (result, serverUrl) => {
|
|
|
1655
1691
|
const accessRights = dcatField("access_rights");
|
|
1656
1692
|
if (accessRights) markdown += `- **Access Rights (dct:accessRights)**: ${sanitizeInline(accessRights)}
|
|
1657
1693
|
`;
|
|
1694
|
+
const periods = readTemporalCoverage(result);
|
|
1695
|
+
if (periods.length > 0) {
|
|
1696
|
+
const spans = periods.map((p) => {
|
|
1697
|
+
const span = `${p.start ? formatDate(p.start) : "?"} \u2192 ${p.end ? formatDate(p.end) : "open"}`;
|
|
1698
|
+
return startEqualsIssued(p, result.issued) ? `${span} (start equals issued, no end)` : span;
|
|
1699
|
+
});
|
|
1700
|
+
markdown += `- **Temporal Coverage (dct:temporal)**: ${sanitizeInline(spans.join("; "))}
|
|
1701
|
+
`;
|
|
1702
|
+
}
|
|
1658
1703
|
markdown += `
|
|
1659
1704
|
`;
|
|
1660
1705
|
if (result.organization) {
|
|
@@ -1888,6 +1933,7 @@ function compactPackageShow(result, serverUrl) {
|
|
|
1888
1933
|
holder_name: result.holder_name || null,
|
|
1889
1934
|
hvd_category: result.hvd_category || null,
|
|
1890
1935
|
applicable_legislation: result.applicable_legislation || null,
|
|
1936
|
+
temporal_coverage: readTemporalCoverage(result).map((p) => ({ ...p, start_equals_issued: startEqualsIssued(p, result.issued) })),
|
|
1891
1937
|
resources: (result.resources || []).map((r) => ({
|
|
1892
1938
|
id: r.id,
|
|
1893
1939
|
name: r.name || null,
|
|
@@ -2500,6 +2546,8 @@ Returns (JSON format):
|
|
|
2500
2546
|
author, maintainer,
|
|
2501
2547
|
frequency, language, publisher_name, holder_name,
|
|
2502
2548
|
hvd_category, applicable_legislation,
|
|
2549
|
+
temporal_coverage (array of {start, end, start_equals_issued} from dct:temporal; empty if absent;
|
|
2550
|
+
start_equals_issued=true when start = issued and no end: on dcatapit portals an export default, not a data period),
|
|
2503
2551
|
resources (id, name, format, url, size, datastore_active, created, last_modified, api_json_url),
|
|
2504
2552
|
view_url, api_json_url
|
|
2505
2553
|
|
|
@@ -4497,7 +4545,55 @@ async function fetchMqaV1(europeanId, links) {
|
|
|
4497
4545
|
...links
|
|
4498
4546
|
};
|
|
4499
4547
|
}
|
|
4500
|
-
|
|
4548
|
+
var STATUS_METRICS = /* @__PURE__ */ new Set(["accessUrlStatusCode", "downloadUrlStatusCode"]);
|
|
4549
|
+
var HTTP_STATUS_CODE = "http://www.w3.org/2011/http#statusCodeValue";
|
|
4550
|
+
function extractStatusCodes(metricsData) {
|
|
4551
|
+
const latest = /* @__PURE__ */ new Map();
|
|
4552
|
+
const graph = decodeMetricsPayload(metricsData)?.["@graph"];
|
|
4553
|
+
for (const node of Array.isArray(graph) ? graph : []) {
|
|
4554
|
+
if (!node || typeof node !== "object") continue;
|
|
4555
|
+
const record = node;
|
|
4556
|
+
const metricRef = record["dqv:isMeasurementOf"];
|
|
4557
|
+
const metricId = typeof metricRef === "string" ? metricRef : metricRef?.["@id"];
|
|
4558
|
+
if (typeof metricId !== "string") continue;
|
|
4559
|
+
const metric = metricKeyFromId(metricId);
|
|
4560
|
+
if (!STATUS_METRICS.has(metric)) continue;
|
|
4561
|
+
const raw = record[HTTP_STATUS_CODE] !== void 0 ? Number(record[HTTP_STATUS_CODE]) : parseMetricValue(record["dqv:value"]);
|
|
4562
|
+
if (typeof raw !== "number" || !Number.isInteger(raw)) continue;
|
|
4563
|
+
const code = raw;
|
|
4564
|
+
const target = record["dqv:computedOn"]?.["@id"];
|
|
4565
|
+
const time = String(record["prov:generatedAtTime"]?.["@value"] ?? "");
|
|
4566
|
+
const key = `${metric} ${typeof target === "string" ? target : ""}`;
|
|
4567
|
+
const current = latest.get(key);
|
|
4568
|
+
if (current && current.time > time) continue;
|
|
4569
|
+
const description = typeof record["dct:description"] === "string" ? record["dct:description"] : "";
|
|
4570
|
+
const label = code >= 1e3 ? `${code} ${/timeout/i.test(description) ? "timeout" : "connection error"}` : String(code);
|
|
4571
|
+
latest.set(key, { metric, code, label, time });
|
|
4572
|
+
}
|
|
4573
|
+
const counts = /* @__PURE__ */ new Map();
|
|
4574
|
+
for (const { metric, code, label } of latest.values()) {
|
|
4575
|
+
if (code >= 200 && code < 300) continue;
|
|
4576
|
+
const perMetric = counts.get(metric) ?? {};
|
|
4577
|
+
perMetric[label] = (perMetric[label] ?? 0) + 1;
|
|
4578
|
+
counts.set(metric, perMetric);
|
|
4579
|
+
}
|
|
4580
|
+
return counts;
|
|
4581
|
+
}
|
|
4582
|
+
async function addHttpStatus(result) {
|
|
4583
|
+
const urlTests = result.failing.filter((item) => item.entity === "distribution" && STATUS_METRICS.has(item.metric));
|
|
4584
|
+
if (urlTests.length === 0) return;
|
|
4585
|
+
let counts;
|
|
4586
|
+
try {
|
|
4587
|
+
counts = extractStatusCodes(await fetchMetricsGraph(`${MQA_METRICS_BASE}/${result.portalId}/metrics`));
|
|
4588
|
+
} catch {
|
|
4589
|
+
return;
|
|
4590
|
+
}
|
|
4591
|
+
for (const item of urlTests) {
|
|
4592
|
+
const perCode = counts.get(item.metric);
|
|
4593
|
+
if (perCode) item.httpStatus = perCode;
|
|
4594
|
+
}
|
|
4595
|
+
}
|
|
4596
|
+
async function getMqaQuality(serverUrl, datasetId, opts = {}) {
|
|
4501
4597
|
const dataset = await makeCkanRequest(
|
|
4502
4598
|
serverUrl,
|
|
4503
4599
|
"package_show",
|
|
@@ -4534,7 +4630,11 @@ async function getMqaQuality(serverUrl, datasetId) {
|
|
|
4534
4630
|
if (!entry || typeof entry !== "object") {
|
|
4535
4631
|
throw new Error("Unexpected MQA payload: no result");
|
|
4536
4632
|
}
|
|
4537
|
-
|
|
4633
|
+
const result = parseMqaV2(entry, links);
|
|
4634
|
+
if (opts.httpStatus) {
|
|
4635
|
+
await addHttpStatus(result);
|
|
4636
|
+
}
|
|
4637
|
+
return result;
|
|
4538
4638
|
}
|
|
4539
4639
|
throw new Error(
|
|
4540
4640
|
`No MQA record on data.europa.eu for this dataset (identifier may not be aligned). Tried: ${candidates.join(", ")}. Check the dataset quality page on data.europa.eu to confirm the identifier (it may include a '~~1' suffix) or verify alignment on dati.gov.it (quality may be marked as 'Non disponibile o identificativo non allineato').`
|
|
@@ -4545,7 +4645,14 @@ function formatNumber(value) {
|
|
|
4545
4645
|
}
|
|
4546
4646
|
function describeFailing(item) {
|
|
4547
4647
|
const scope = item.entity === "dataset" ? "dataset" : `${item.failed} of ${item.total} ${item.entity === "distribution" ? "distributions" : "data services"}`;
|
|
4548
|
-
|
|
4648
|
+
let http = "";
|
|
4649
|
+
if (item.httpStatus) {
|
|
4650
|
+
const codes = Object.entries(item.httpStatus).sort((a, b) => b[1] - a[1]).map(([code, n]) => `${code} \xD7${n}`);
|
|
4651
|
+
const unexplained = item.failed - Object.values(item.httpStatus).reduce((sum, n) => sum + n, 0);
|
|
4652
|
+
if (unexplained > 0) codes.push(`no status recorded \xD7${unexplained}`);
|
|
4653
|
+
http = `; HTTP test: ${codes.join(", ")}`;
|
|
4654
|
+
}
|
|
4655
|
+
return `\`${item.property}\` (${item.metric}, ${item.importance.toLowerCase()} ${item.weight}) - ${scope}, +${formatNumber(item.gain)}${http}`;
|
|
4549
4656
|
}
|
|
4550
4657
|
function pushV2Scores(lines, result) {
|
|
4551
4658
|
lines.push(`**Score**: ${formatNumber(result.score)}/${result.maxScore} (${result.band})`);
|
|
@@ -4704,7 +4811,7 @@ The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and on
|
|
|
4704
4811
|
"ckan_get_mqa_quality_details",
|
|
4705
4812
|
{
|
|
4706
4813
|
title: "Get MQA Quality Details",
|
|
4707
|
-
description: "Get detailed MQA (Metadata Quality Assessment) quality reasons for a dataset on dati.gov.it. Lists every failing metric grouped by FAIR dimension, with DCAT-AP property, weight, how many distributions fail it and its gain on the final score (methodology v2); previous-methodology datasets get non-max reasons. Only works with dati.gov.it server. Typical workflow: ckan_get_mqa_quality (get overview scores) \u2192 ckan_get_mqa_quality_details (inspect failing metrics)",
|
|
4814
|
+
description: "Get detailed MQA (Metadata Quality Assessment) quality reasons for a dataset on dati.gov.it. Lists every failing metric grouped by FAIR dimension, with DCAT-AP property, weight, how many distributions fail it and its gain on the final score, plus the HTTP status of failing URL tests (e.g. 1100 timeout, 404) (methodology v2); previous-methodology datasets get non-max reasons. Only works with dati.gov.it server. Typical workflow: ckan_get_mqa_quality (get overview scores) \u2192 ckan_get_mqa_quality_details (inspect failing metrics)",
|
|
4708
4815
|
inputSchema: z8.object({
|
|
4709
4816
|
server_url: z8.string().url().describe("Base URL of dati.gov.it (e.g., https://www.dati.gov.it/opendata)"),
|
|
4710
4817
|
dataset_id: z8.string().describe("Dataset ID or name"),
|
|
@@ -4733,7 +4840,7 @@ The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and on
|
|
|
4733
4840
|
};
|
|
4734
4841
|
}
|
|
4735
4842
|
try {
|
|
4736
|
-
const details = await getMqaQuality(server_url, dataset_id);
|
|
4843
|
+
const details = await getMqaQuality(server_url, dataset_id, { httpStatus: true });
|
|
4737
4844
|
const format = response_format || "markdown" /* MARKDOWN */;
|
|
4738
4845
|
const output = format === "json" /* JSON */ ? truncateJson(details) : truncateText(addDemoFooter(formatQualityDetailsMarkdown(details, dataset_id)));
|
|
4739
4846
|
return {
|
|
@@ -5935,7 +6042,7 @@ var registerAllPrompts = (server) => {
|
|
|
5935
6042
|
function createServer() {
|
|
5936
6043
|
return new McpServer({
|
|
5937
6044
|
name: "ckan-mcp-server",
|
|
5938
|
-
version: "0.4.
|
|
6045
|
+
version: "0.4.127"
|
|
5939
6046
|
});
|
|
5940
6047
|
}
|
|
5941
6048
|
function registerAll(server) {
|