@aborruso/ckan-mcp-server 0.4.124 → 0.4.125

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/EXAMPLES.md CHANGED
@@ -160,14 +160,14 @@ ckan_get_mqa_quality({
160
160
  })
161
161
  ```
162
162
 
163
- Returns quality score and detailed metrics from data.europa.eu MQA (Metadata Quality Assurance) system:
164
- - Overall score (max 405 points)
165
- - Accessibility (URL status, download availability)
166
- - Reusability (license, contact point, publisher)
167
- - Interoperability (format, media type)
168
- - Findability (keywords, category, spatial/temporal coverage)
169
-
170
- **Note**: Only works with dati.gov.it datasets. Uses the `identifier` field (or falls back to `name`) to query the European MQA API.
163
+ Returns the quality score from the data.europa.eu MQA (Metadata Quality Assessment), [methodology v2](https://data.europa.eu/mqa/methodology):
164
+ - Final score on a 0-7.5 scale and its band (Sufficient < 2.5 ≤ Good < 5 ≤ Excellent)
165
+ - Dataset score, distribution average and data service average
166
+ - The failing metrics with the largest gain on the final score (DCAT-AP property, weight, how many distributions fail it)
167
+
168
+ `ckan_get_mqa_quality_details` lists every failing metric grouped by FAIR dimension (findability, accessibility, interoperability, reusability).
169
+
170
+ **Note**: Only works with dati.gov.it datasets. Uses the `identifier` field (or falls back to `name`) to query the European MQA API. Datasets that data.europa.eu has not re-evaluated with v2 yet are reported with the previous methodology (405-point scale), labelled as such.
171
171
 
172
172
  ## USA - data.gov
173
173
 
package/LOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-09-23
4
+
5
+ ### v0.4.125 - MQA scores on the new data.europa.eu methodology
6
+
7
+ 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`.
8
+
9
+ ### MQA tools follow the data.europa.eu methodology v2 (#548)
10
+
11
+ - data.europa.eu switched the MQA to a new methodology (DCAT-AP 3, weights 1/0.5/0.25, 0-7.5 scale, four FAIR dimensions, no Contextuality, SHACL not scored). The dataset cache now serves only v2 and answers `404 No v2 metrics found` for datasets not yet re-evaluated: on day one, 1,051 of 61,897 dati.gov.it datasets.
12
+ - Both MQA tools were broken: re-evaluated datasets printed `6.5/405` and `Accessibility 2.25/100`, the JSON of a 24-distribution dataset exceeded 50k and came back `{_truncated}`; the others failed with a wrong "identifier not aligned" error.
13
+ - Now: v2 payload parsed directly — `datasetFinal`/7.5 with band, dataset/distribution/data service scores, failing metrics aggregated across distributions ("21 of 24") and ordered by their exact gain on the final score. Checked live: score + sum of gains = 7.5.
14
+ - `result: null` (test not run, e.g. no URL) counts as failing, as it does in the official score; found only on the live payload.
15
+ - No v2 yet → previous-methodology scores from the metrics endpoint, labelled; refused if the graph already carries v2-scale numbers.
16
+ - MQA tools were already broken on the Worker (`The 'cache' field on 'RequestInitializerDict' is not implemented`, axios fetch adapter): the cache call now goes through `safeFetch` like the metrics call. Verified with `wrangler dev` and the Node HTTP server.
17
+ - `DQV of dataset not found` and `No v2 metrics found` now give different errors. JSON output is compact (`methodology`, `metricsVersion`, `maxScore`), never the raw payload. OpenSpec change `update-mqa-methodology-v2`.
18
+
3
19
  ## 2026-09-06
4
20
 
5
21
  ### v0.4.124 - search that answers the question that was asked
package/dist/index.js CHANGED
@@ -4061,7 +4061,6 @@ Typical workflow: ckan_group_search \u2192 ckan_group_show (get details) \u2192
4061
4061
 
4062
4062
  // src/tools/quality.ts
4063
4063
  import { z as z8 } from "zod";
4064
- import axios2 from "axios";
4065
4064
  var MQA_API_BASE = "https://data.europa.eu/api/mqa/cache/datasets";
4066
4065
  var MQA_METRICS_BASE = "https://data.europa.eu/api/hub/repo/datasets";
4067
4066
  var ALLOWED_MQA_HOSTS = /* @__PURE__ */ new Set(["dati.gov.it", "www.dati.gov.it"]);
@@ -4351,338 +4350,302 @@ function findNonMaxDimensions(scores) {
4351
4350
  });
4352
4351
  return nonMax;
4353
4352
  }
4354
- async function fetchMqaQuality(serverUrl, datasetId) {
4355
- const dataset = await makeCkanRequest(
4356
- serverUrl,
4357
- "package_show",
4358
- { id: datasetId }
4359
- );
4360
- const baseIdentifier = dataset.identifier || dataset.name;
4361
- const candidates = buildMqaIdCandidates(baseIdentifier);
4362
- if (candidates.length === 0) {
4363
- throw new Error("Dataset identifier is empty; cannot query MQA API");
4364
- }
4365
- for (const europeanId of candidates) {
4366
- const mqaUrl = `${MQA_API_BASE}/${europeanId}`;
4367
- const metricsUrl = `${MQA_METRICS_BASE}/${europeanId}/metrics`;
4368
- try {
4369
- const response = await axios2.get(mqaUrl, {
4370
- timeout: 3e4,
4371
- headers: {
4372
- "User-Agent": "CKAN-MCP-Server/1.0"
4373
- }
4374
- });
4375
- let metricsPayload;
4376
- try {
4377
- const metricsResponse = await safeFetch(metricsUrl, {
4378
- headers: {
4379
- "User-Agent": "CKAN-MCP-Server/1.0"
4380
- }
4381
- }, { httpsOnly: true });
4382
- if (!metricsResponse.ok) {
4383
- throw new Error(`MQA metrics error: ${metricsResponse.status} ${metricsResponse.statusText}`);
4384
- }
4385
- try {
4386
- metricsPayload = await metricsResponse.json();
4387
- } catch {
4388
- metricsPayload = await metricsResponse.text();
4389
- }
4390
- } catch (metricsError) {
4391
- if (metricsError instanceof Error) {
4392
- throw new Error(`MQA metrics error: ${metricsError.message}`);
4393
- }
4394
- throw metricsError;
4395
- }
4396
- const scores = extractMetricsScores(metricsPayload);
4397
- const resultEntry = response.data?.result?.results?.[0];
4398
- const portalId = resultEntry?.info?.["dataset-id"] || europeanId;
4399
- const breakdown = {
4400
- scores,
4401
- nonMaxDimensions: findNonMaxDimensions(scores),
4402
- metricsUrl,
4403
- mqaUrl,
4404
- portalId
4405
- };
4406
- return {
4407
- mqa: response.data,
4408
- metrics: metricsPayload,
4409
- breakdown
4410
- };
4411
- } catch (error) {
4412
- if (axios2.isAxiosError(error)) {
4413
- if (error.response?.status === 404) {
4414
- continue;
4415
- }
4416
- throw new Error(`MQA API error: ${error.message}`);
4417
- }
4418
- throw error;
4353
+ var V2_MAX_SCORE = 7.5;
4354
+ var V1_MAX_SCORE = 405;
4355
+ var V2_DIMENSIONS = ["findability", "accessibility", "interoperability", "reusability"];
4356
+ var V1_NOTE = "Not yet re-evaluated with MQA methodology v2: scores come from the previous MQA methodology (405-point scale, five dimensions).";
4357
+ var PROPERTY_PREFIXES = [
4358
+ ["http://purl.org/dc/terms/", "dct:"],
4359
+ ["http://www.w3.org/ns/dcat#", "dcat:"],
4360
+ ["http://www.w3.org/ns/adms#", "adms:"],
4361
+ ["http://xmlns.com/foaf/0.1/", "foaf:"],
4362
+ ["http://data.europa.eu/r5r/", "dcatap:"]
4363
+ ];
4364
+ function shortProperty(uri) {
4365
+ for (const [namespace, prefix] of PROPERTY_PREFIXES) {
4366
+ if (uri.startsWith(namespace)) {
4367
+ return prefix + uri.slice(namespace.length);
4419
4368
  }
4420
4369
  }
4421
- throw new Error(
4422
- `Quality metrics not found or identifier not aligned on data.europa.eu. 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').`
4423
- );
4370
+ return uri;
4424
4371
  }
4425
- async function getMqaQuality(serverUrl, datasetId) {
4426
- const result = await fetchMqaQuality(serverUrl, datasetId);
4427
- return {
4428
- mqa: result.mqa,
4429
- breakdown: result.breakdown
4430
- };
4372
+ function mqaBand(score) {
4373
+ if (score >= 5) return "Excellent";
4374
+ if (score >= 2.5) return "Good";
4375
+ return "Sufficient";
4431
4376
  }
4432
- async function getMqaQualityDetails(serverUrl, datasetId) {
4433
- const result = await fetchMqaQuality(serverUrl, datasetId);
4434
- const details = extractMetricDetails(result.metrics, result.breakdown.nonMaxDimensions);
4435
- return {
4436
- breakdown: result.breakdown,
4437
- details
4438
- };
4377
+ function round(value, digits = 3) {
4378
+ const factor = 10 ** digits;
4379
+ return Math.round(value * factor) / factor;
4439
4380
  }
4440
- function findSectionMetric(section, key) {
4441
- if (!Array.isArray(section)) {
4442
- return void 0;
4443
- }
4444
- for (const item of section) {
4445
- if (item && typeof item === "object" && key in item) {
4446
- return item[key];
4447
- }
4381
+ function groupScore(entities) {
4382
+ if (entities.length === 0) {
4383
+ return null;
4448
4384
  }
4449
- return void 0;
4385
+ const average = entities.reduce((sum, entity) => sum + entity.score, 0) / entities.length;
4386
+ return { count: entities.length, average: round(average) };
4450
4387
  }
4451
- function metricArrayIsAvailable(metric) {
4452
- if (!Array.isArray(metric)) {
4453
- return void 0;
4388
+ function parseMqaV2(entry, links) {
4389
+ const dataset = entry.dataset;
4390
+ if (!dataset || typeof dataset.score !== "number") {
4391
+ throw new Error("Unexpected MQA payload: missing dataset score");
4454
4392
  }
4455
- const byName = /* @__PURE__ */ new Map();
4456
- for (const entry of metric) {
4457
- if (!entry || typeof entry !== "object") {
4458
- continue;
4459
- }
4460
- const name = entry.name;
4461
- const percentage = entry.percentage;
4462
- if (typeof name === "string" && typeof percentage === "number") {
4463
- byName.set(name.toLowerCase(), percentage);
4464
- }
4465
- }
4466
- if (byName.has("yes")) {
4467
- return (byName.get("yes") || 0) > 0;
4468
- }
4469
- if (byName.size > 0) {
4470
- for (const [name, percentage] of byName.entries()) {
4471
- if (name.startsWith("2") && percentage > 0) {
4472
- return true;
4393
+ const groups = [
4394
+ ["dataset", [dataset]],
4395
+ ["distribution", Array.isArray(entry.distributions) ? entry.distributions : []],
4396
+ ["dataService", Array.isArray(entry.dataServices) ? entry.dataServices : []]
4397
+ ];
4398
+ const groupsPresent = groups.filter(([, entities]) => entities.length > 0).length;
4399
+ const score = typeof entry.datasetFinal === "number" ? entry.datasetFinal : groupsPresent === 1 ? dataset.score : void 0;
4400
+ if (score === void 0) {
4401
+ throw new Error("Unexpected MQA payload: missing final dataset score");
4402
+ }
4403
+ const failing = [];
4404
+ for (const [entity, entities] of groups) {
4405
+ const byMetric = /* @__PURE__ */ new Map();
4406
+ for (const item of entities) {
4407
+ for (const metric of item.metrics ?? []) {
4408
+ if (metric.result === 1) continue;
4409
+ const current = byMetric.get(metric.metric);
4410
+ if (current) {
4411
+ current.failed += 1;
4412
+ } else {
4413
+ byMetric.set(metric.metric, { metric, failed: 1 });
4414
+ }
4473
4415
  }
4474
4416
  }
4475
- return false;
4417
+ for (const { metric, failed } of byMetric.values()) {
4418
+ failing.push({
4419
+ metric: metric.metric,
4420
+ property: shortProperty(metric.property),
4421
+ dimension: metric.dimension,
4422
+ importance: metric.importance,
4423
+ weight: metric.weight,
4424
+ entity,
4425
+ failed,
4426
+ total: entities.length,
4427
+ // The group average spans all its entities, and the final score averages the groups
4428
+ gain: round(metric.weight * failed / entities.length / groupsPresent, 4)
4429
+ });
4430
+ }
4476
4431
  }
4477
- return void 0;
4432
+ failing.sort((a, b) => b.gain - a.gain || a.metric.localeCompare(b.metric));
4433
+ return {
4434
+ methodology: "v2",
4435
+ metricsVersion: typeof entry.metricsVersion === "string" ? entry.metricsVersion : "2",
4436
+ score: round(score, 4),
4437
+ maxScore: V2_MAX_SCORE,
4438
+ band: mqaBand(score),
4439
+ dataset: { score: dataset.score, maxScore: V2_MAX_SCORE },
4440
+ distributions: groupScore(groups[1][1]),
4441
+ dataServices: groupScore(groups[2][1]),
4442
+ failing,
4443
+ ...links
4444
+ };
4478
4445
  }
4479
- function metricBoolean(section, key) {
4480
- const metric = findSectionMetric(section, key);
4481
- if (typeof metric === "boolean") {
4482
- return metric;
4483
- }
4484
- return void 0;
4446
+ var MQA_TIMEOUT_MS = 3e4;
4447
+ function fetchMqa(url) {
4448
+ return safeFetch(url, {
4449
+ headers: { "User-Agent": "CKAN-MCP-Server/1.0" },
4450
+ signal: AbortSignal.timeout(MQA_TIMEOUT_MS)
4451
+ }, { httpsOnly: true });
4485
4452
  }
4486
- function metricAvailability(section, availabilityKey, statusKey) {
4487
- const availabilityMetric = findSectionMetric(section, availabilityKey);
4488
- const availability = metricArrayIsAvailable(availabilityMetric);
4489
- if (availability !== void 0) {
4490
- return { available: availability };
4491
- }
4492
- if (statusKey) {
4493
- const statusMetric = findSectionMetric(section, statusKey);
4494
- const statusAvailable = metricArrayIsAvailable(statusMetric);
4495
- if (statusAvailable !== void 0) {
4496
- return { available: statusAvailable };
4453
+ async function fetchMetricsGraph(metricsUrl) {
4454
+ try {
4455
+ const response = await fetchMqa(metricsUrl);
4456
+ if (!response.ok) {
4457
+ throw new Error(`${response.status} ${response.statusText}`);
4497
4458
  }
4459
+ try {
4460
+ return await response.json();
4461
+ } catch {
4462
+ return await response.text();
4463
+ }
4464
+ } catch (error) {
4465
+ throw new Error(`MQA metrics error: ${error instanceof Error ? error.message : String(error)}`);
4498
4466
  }
4499
- return void 0;
4500
4467
  }
4501
- function normalizeQualityData(data) {
4502
- const d = data;
4503
- const mqaData = d?.mqa ?? data;
4504
- const breakdown = d?.breakdown;
4505
- const resultEntry = mqaData?.result?.results?.[0];
4506
- if (!resultEntry || typeof resultEntry !== "object") {
4507
- return { ...data, breakdown };
4468
+ async function fetchMqaV1(europeanId, links) {
4469
+ const metricsUrl = `${MQA_METRICS_BASE}/${europeanId}/metrics`;
4470
+ const notReEvaluated = `Dataset ${europeanId} is on data.europa.eu but not yet re-evaluated with MQA methodology v2, and previous-methodology metrics are not available`;
4471
+ let metrics;
4472
+ try {
4473
+ metrics = await fetchMetricsGraph(metricsUrl);
4474
+ } catch (error) {
4475
+ throw new Error(`${notReEvaluated} (${error instanceof Error ? error.message : String(error)}).`);
4476
+ }
4477
+ const scores = extractMetricsScores(metrics);
4478
+ const looksV1 = scores.contextuality !== void 0 || (scores.total ?? 0) > V2_MAX_SCORE;
4479
+ if (!looksV1) {
4480
+ throw new Error(`${notReEvaluated}. Try again after the next harvest.`);
4508
4481
  }
4482
+ const nonMaxDimensions = findNonMaxDimensions(scores);
4509
4483
  return {
4510
- id: resultEntry.info?.["dataset-id"],
4511
- info: { score: resultEntry.info?.score },
4512
- accessibility: {
4513
- accessUrl: metricAvailability(resultEntry.accessibility, "accessUrlAvailability", "accessUrlStatusCode"),
4514
- downloadUrl: metricAvailability(resultEntry.accessibility, "downloadUrlAvailability", "downloadUrlStatusCode")
4515
- },
4516
- reusability: {
4517
- licence: metricAvailability(resultEntry.reusability, "licenceAvailability"),
4518
- contactPoint: metricBoolean(resultEntry.reusability, "contactPointAvailability") !== void 0 ? { available: metricBoolean(resultEntry.reusability, "contactPointAvailability") } : void 0,
4519
- publisher: metricBoolean(resultEntry.reusability, "publisherAvailability") !== void 0 ? { available: metricBoolean(resultEntry.reusability, "publisherAvailability") } : void 0
4520
- },
4521
- interoperability: {
4522
- format: metricAvailability(resultEntry.interoperability, "formatAvailability"),
4523
- mediaType: metricAvailability(resultEntry.interoperability, "mediaTypeAvailability")
4484
+ methodology: "v1",
4485
+ metricsVersion: "1",
4486
+ note: V1_NOTE,
4487
+ score: scores.total ?? null,
4488
+ maxScore: V1_MAX_SCORE,
4489
+ breakdown: {
4490
+ scores,
4491
+ nonMaxDimensions,
4492
+ metricsUrl,
4493
+ mqaUrl: links.mqaUrl,
4494
+ portalId: europeanId
4524
4495
  },
4525
- findability: {
4526
- keyword: metricBoolean(resultEntry.findability, "keywordAvailability") !== void 0 ? { available: metricBoolean(resultEntry.findability, "keywordAvailability") } : void 0,
4527
- category: metricBoolean(resultEntry.findability, "categoryAvailability") !== void 0 ? { available: metricBoolean(resultEntry.findability, "categoryAvailability") } : void 0,
4528
- spatial: metricBoolean(resultEntry.findability, "spatialAvailability") !== void 0 ? { available: metricBoolean(resultEntry.findability, "spatialAvailability") } : void 0,
4529
- temporal: metricBoolean(resultEntry.findability, "temporalAvailability") !== void 0 ? { available: metricBoolean(resultEntry.findability, "temporalAvailability") } : void 0
4530
- },
4531
- contextuality: {
4532
- byteSize: metricAvailability(resultEntry.contextuality, "byteSizeAvailability"),
4533
- rights: metricAvailability(resultEntry.contextuality, "rightsAvailability")
4534
- },
4535
- breakdown
4496
+ details: extractMetricDetails(metrics, nonMaxDimensions),
4497
+ ...links
4536
4498
  };
4537
4499
  }
4538
- function formatQualityMarkdown(data, datasetId) {
4539
- const normalized = normalizeQualityData(data);
4540
- const lines = [];
4541
- lines.push(`# Quality Metrics for Dataset: ${datasetId}`);
4542
- lines.push("");
4543
- if (normalized.info?.score !== void 0) {
4544
- lines.push(`**Overall Score**: ${normalized.info.score}/405`);
4545
- lines.push("");
4500
+ async function getMqaQuality(serverUrl, datasetId) {
4501
+ const dataset = await makeCkanRequest(
4502
+ serverUrl,
4503
+ "package_show",
4504
+ { id: datasetId }
4505
+ );
4506
+ const baseIdentifier = dataset.identifier || dataset.name;
4507
+ const candidates = buildMqaIdCandidates(baseIdentifier);
4508
+ if (candidates.length === 0) {
4509
+ throw new Error("Dataset identifier is empty; cannot query MQA API");
4546
4510
  }
4547
- if (normalized.breakdown?.scores) {
4548
- lines.push("## Dimension Scores");
4549
- const scores = normalized.breakdown.scores;
4550
- const order = [
4551
- ["accessibility", "Accessibility", DIMENSION_MAX.accessibility],
4552
- ["findability", "Findability", DIMENSION_MAX.findability],
4553
- ["interoperability", "Interoperability", DIMENSION_MAX.interoperability],
4554
- ["reusability", "Reusability", DIMENSION_MAX.reusability],
4555
- ["contextuality", "Contextuality", DIMENSION_MAX.contextuality]
4556
- ];
4557
- for (const [key, label, max] of order) {
4558
- const value = scores[key];
4559
- if (typeof value === "number") {
4560
- const isMax = value >= max;
4561
- const status = isMax ? "\u2705" : "\u26A0\uFE0F";
4562
- lines.push(`- ${label}: ${value}/${max} ${status}${isMax ? "" : ` (max ${max})`}`);
4563
- }
4511
+ for (const europeanId of candidates) {
4512
+ const links = {
4513
+ portalId: europeanId,
4514
+ portalUrl: `https://data.europa.eu/data/datasets/${europeanId}/quality?locale=it`,
4515
+ mqaUrl: `${MQA_API_BASE}/${europeanId}`
4516
+ };
4517
+ let response;
4518
+ try {
4519
+ response = await fetchMqa(links.mqaUrl);
4520
+ } catch (error) {
4521
+ throw new Error(`MQA API error: ${error instanceof Error ? error.message : String(error)}`);
4564
4522
  }
4565
- if (normalized.breakdown.nonMaxDimensions.length > 0) {
4566
- lines.push(`- Non-max dimension(s): ${normalized.breakdown.nonMaxDimensions.join(", ")}`);
4567
- } else if (Object.keys(scores).length > 0) {
4568
- lines.push("- Non-max dimension(s): none");
4523
+ if (response.status === 404) {
4524
+ if (/no v2 metrics/i.test(await response.text())) {
4525
+ return fetchMqaV1(europeanId, links);
4526
+ }
4527
+ continue;
4569
4528
  }
4570
- lines.push("");
4571
- }
4572
- if (normalized.accessibility) {
4573
- lines.push("## Accessibility");
4574
- if (normalized.accessibility.accessUrl !== void 0) {
4575
- lines.push(`- Access URL: ${normalized.accessibility.accessUrl.available ? "\u2713" : "\u2717"} Available`);
4529
+ if (!response.ok) {
4530
+ throw new Error(`MQA API error: ${response.status} ${response.statusText}`);
4576
4531
  }
4577
- if (normalized.accessibility.downloadUrl !== void 0) {
4578
- lines.push(`- Download URL: ${normalized.accessibility.downloadUrl.available ? "\u2713" : "\u2717"} Available`);
4532
+ const payload = await response.json();
4533
+ const entry = payload?.result?.results?.[0];
4534
+ if (!entry || typeof entry !== "object") {
4535
+ throw new Error("Unexpected MQA payload: no result");
4579
4536
  }
4580
- lines.push("");
4537
+ return parseMqaV2(entry, links);
4581
4538
  }
4582
- if (normalized.reusability) {
4583
- lines.push("## Reusability");
4584
- if (normalized.reusability.licence !== void 0) {
4585
- lines.push(`- License: ${normalized.reusability.licence.available ? "\u2713" : "\u2717"} Available`);
4586
- }
4587
- if (normalized.reusability.contactPoint !== void 0) {
4588
- lines.push(`- Contact Point: ${normalized.reusability.contactPoint.available ? "\u2713" : "\u2717"} Available`);
4589
- }
4590
- if (normalized.reusability.publisher !== void 0) {
4591
- lines.push(`- Publisher: ${normalized.reusability.publisher.available ? "\u2713" : "\u2717"} Available`);
4592
- }
4593
- lines.push("");
4539
+ throw new Error(
4540
+ `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').`
4541
+ );
4542
+ }
4543
+ function formatNumber(value) {
4544
+ return String(round(value, 2));
4545
+ }
4546
+ function describeFailing(item) {
4547
+ const scope = item.entity === "dataset" ? "dataset" : `${item.failed} of ${item.total} ${item.entity === "distribution" ? "distributions" : "data services"}`;
4548
+ return `\`${item.property}\` (${item.metric}, ${item.importance.toLowerCase()} ${item.weight}) - ${scope}, +${formatNumber(item.gain)}`;
4549
+ }
4550
+ function pushV2Scores(lines, result) {
4551
+ lines.push(`**Score**: ${formatNumber(result.score)}/${result.maxScore} (${result.band})`);
4552
+ lines.push(`Methodology: MQA v2 (metrics ${result.metricsVersion}). Bands: Sufficient < 2.5 \u2264 Good < 5 \u2264 Excellent.`);
4553
+ lines.push("");
4554
+ lines.push("## Scores");
4555
+ lines.push(`- Dataset: ${formatNumber(result.dataset.score)}/${result.dataset.maxScore}`);
4556
+ if (result.distributions) {
4557
+ lines.push(`- Distributions (${result.distributions.count}): average ${formatNumber(result.distributions.average)}/${result.maxScore}`);
4594
4558
  }
4595
- if (normalized.interoperability) {
4596
- lines.push("## Interoperability");
4597
- if (normalized.interoperability.format !== void 0) {
4598
- lines.push(`- Format: ${normalized.interoperability.format.available ? "\u2713" : "\u2717"} Available`);
4599
- }
4600
- if (normalized.interoperability.mediaType !== void 0) {
4601
- lines.push(`- Media Type: ${normalized.interoperability.mediaType.available ? "\u2713" : "\u2717"} Available`);
4602
- }
4603
- lines.push("");
4559
+ if (result.dataServices) {
4560
+ lines.push(`- Data services (${result.dataServices.count}): average ${formatNumber(result.dataServices.average)}/${result.maxScore}`);
4604
4561
  }
4605
- if (normalized.findability) {
4606
- lines.push("## Findability");
4607
- if (normalized.findability.keyword !== void 0) {
4608
- lines.push(`- Keywords: ${normalized.findability.keyword.available ? "\u2713" : "\u2717"} Available`);
4609
- }
4610
- if (normalized.findability.category !== void 0) {
4611
- lines.push(`- Category: ${normalized.findability.category.available ? "\u2713" : "\u2717"} Available`);
4612
- }
4613
- if (normalized.findability.spatial !== void 0) {
4614
- lines.push(`- Spatial: ${normalized.findability.spatial.available ? "\u2713" : "\u2717"} Available`);
4615
- }
4616
- if (normalized.findability.temporal !== void 0) {
4617
- lines.push(`- Temporal: ${normalized.findability.temporal.available ? "\u2713" : "\u2717"} Available`);
4618
- }
4562
+ lines.push("");
4563
+ }
4564
+ function pushV1Scores(lines, result) {
4565
+ lines.push(`> ${result.note}`);
4566
+ lines.push("");
4567
+ const scores = result.breakdown.scores;
4568
+ if (typeof scores.total === "number") {
4569
+ lines.push(`**Score**: ${scores.total}/${result.maxScore}`);
4619
4570
  lines.push("");
4620
4571
  }
4621
- if (normalized.contextuality) {
4622
- lines.push("## Contextuality");
4623
- if (normalized.contextuality.byteSize !== void 0) {
4624
- lines.push(`- Byte Size: ${normalized.contextuality.byteSize.available ? "\u2713" : "\u2717"} Available`);
4572
+ lines.push("## Dimension Scores");
4573
+ for (const dimension of Object.keys(DIMENSION_MAX)) {
4574
+ const value = scores[dimension];
4575
+ if (typeof value !== "number") continue;
4576
+ const max = DIMENSION_MAX[dimension];
4577
+ const isMax = value >= max;
4578
+ lines.push(`- ${DIMENSION_LABELS[dimension]}: ${value}/${max} ${isMax ? "\u2705" : "\u26A0\uFE0F"}`);
4579
+ }
4580
+ lines.push("");
4581
+ }
4582
+ function pushLinks(lines, result) {
4583
+ lines.push("---");
4584
+ lines.push(`Portal: ${result.portalUrl}`);
4585
+ lines.push(`MQA source: ${result.mqaUrl}`);
4586
+ if (result.methodology === "v1") {
4587
+ lines.push(`Metrics endpoint: ${result.breakdown.metricsUrl}`);
4588
+ }
4589
+ }
4590
+ var TOP_FIXES = 5;
4591
+ function formatQualityMarkdown(result, datasetId) {
4592
+ const lines = [`# Quality Metrics for Dataset: ${datasetId}`, ""];
4593
+ if (result.methodology === "v1") {
4594
+ pushV1Scores(lines, result);
4595
+ if (result.breakdown.nonMaxDimensions.length > 0) {
4596
+ lines.push(`Non-max dimension(s): ${result.breakdown.nonMaxDimensions.join(", ")}. Use ckan_get_mqa_quality_details for the reasons.`);
4597
+ lines.push("");
4625
4598
  }
4626
- if (normalized.contextuality.rights !== void 0) {
4627
- lines.push(`- Rights: ${normalized.contextuality.rights.available ? "\u2713" : "\u2717"} Available`);
4599
+ } else {
4600
+ pushV2Scores(lines, result);
4601
+ if (result.failing.length === 0) {
4602
+ lines.push("All evaluated metrics are fulfilled.");
4603
+ } else {
4604
+ lines.push(`## Top fixes (${Math.min(TOP_FIXES, result.failing.length)} of ${result.failing.length} failing metrics, by gain on the final score)`);
4605
+ for (const item of result.failing.slice(0, TOP_FIXES)) {
4606
+ lines.push(`- ${describeFailing(item)}`);
4607
+ }
4608
+ if (result.failing.length > TOP_FIXES) {
4609
+ lines.push("- Use ckan_get_mqa_quality_details for the full list.");
4610
+ }
4628
4611
  }
4629
4612
  lines.push("");
4630
4613
  }
4631
- lines.push("---");
4632
- const portalId = normalized.breakdown?.portalId || normalized.id || datasetId;
4633
- lines.push(`Portal: https://data.europa.eu/data/datasets/${portalId}/quality?locale=it`);
4634
- lines.push(`MQA source: ${MQA_API_BASE}/${portalId}`);
4635
- const metricsEndpoint = normalized.breakdown?.metricsUrl || `${MQA_METRICS_BASE}/${portalId}/metrics`;
4636
- lines.push(`Metrics endpoint: ${metricsEndpoint}`);
4637
- lines.push(`Tip: Use the metrics endpoint to explain score deductions (e.g., failing measurements such as knownLicence = false).`);
4614
+ pushLinks(lines, result);
4638
4615
  return lines.join("\n");
4639
4616
  }
4640
- function formatQualityDetailsMarkdown(data, datasetId) {
4641
- const lines = [];
4642
- const breakdown = data.breakdown;
4643
- lines.push(`# Quality Details for Dataset: ${datasetId}`);
4644
- lines.push("");
4645
- if (typeof breakdown.scores.total === "number") {
4646
- lines.push(`**Overall Score**: ${breakdown.scores.total}/405`);
4647
- lines.push("");
4648
- }
4649
- if (breakdown.scores) {
4650
- lines.push("## Dimension Scores");
4651
- const order = [
4652
- ["accessibility", DIMENSION_MAX.accessibility],
4653
- ["findability", DIMENSION_MAX.findability],
4654
- ["interoperability", DIMENSION_MAX.interoperability],
4655
- ["reusability", DIMENSION_MAX.reusability],
4656
- ["contextuality", DIMENSION_MAX.contextuality]
4657
- ];
4658
- for (const [key, max] of order) {
4659
- const value = breakdown.scores[key];
4660
- if (typeof value === "number") {
4661
- const status = value >= max ? "\u2705" : "\u26A0\uFE0F";
4662
- lines.push(`- ${DIMENSION_LABELS[key]}: ${value}/${max} ${status}${value >= max ? "" : ` (max ${max})`}`);
4663
- }
4617
+ function formatQualityDetailsMarkdown(result, datasetId) {
4618
+ const lines = [`# Quality Details for Dataset: ${datasetId}`, ""];
4619
+ if (result.methodology === "v1") {
4620
+ pushV1Scores(lines, result);
4621
+ lines.push("## Non-max Reasons");
4622
+ if (result.breakdown.nonMaxDimensions.length === 0) {
4623
+ lines.push("- All dimensions are at max score.");
4624
+ }
4625
+ for (const dimension of result.breakdown.nonMaxDimensions) {
4626
+ const reasons = result.details.reasons[dimension] || [];
4627
+ lines.push(`- ${DIMENSION_LABELS[dimension]}: ${reasons.length > 0 ? reasons.join("; ") : "no failing flags detected in metrics payload"}`);
4664
4628
  }
4665
4629
  lines.push("");
4666
- }
4667
- lines.push("## Non-max Reasons");
4668
- if (breakdown.nonMaxDimensions.length === 0) {
4669
- lines.push("- All dimensions are at max score.");
4670
4630
  } else {
4671
- for (const dimension of breakdown.nonMaxDimensions) {
4672
- const reasons = data.details.reasons[dimension] || [];
4673
- if (reasons.length === 0) {
4674
- lines.push(`- ${DIMENSION_LABELS[dimension]}: no failing flags detected in metrics payload`);
4675
- } else {
4676
- lines.push(`- ${DIMENSION_LABELS[dimension]}: ${reasons.join("; ")}`);
4631
+ pushV2Scores(lines, result);
4632
+ if (result.failing.length === 0) {
4633
+ lines.push("All evaluated metrics are fulfilled.");
4634
+ lines.push("");
4635
+ }
4636
+ for (const dimension of V2_DIMENSIONS) {
4637
+ const items = result.failing.filter((item) => item.dimension === dimension);
4638
+ if (items.length === 0) continue;
4639
+ lines.push(`## ${DIMENSION_LABELS[dimension]}`);
4640
+ for (const item of items) {
4641
+ lines.push(`- ${describeFailing(item)}`);
4677
4642
  }
4643
+ lines.push("");
4678
4644
  }
4645
+ lines.push("Gain = increase of the final score if every listed entity fulfilled the metric.");
4646
+ lines.push("");
4679
4647
  }
4680
- lines.push("");
4681
- lines.push("---");
4682
- const portalId = breakdown.portalId || datasetId;
4683
- lines.push(`Portal: https://data.europa.eu/data/datasets/${portalId}/quality?locale=it`);
4684
- lines.push(`MQA source: ${MQA_API_BASE}/${portalId}`);
4685
- lines.push(`Metrics endpoint: ${breakdown.metricsUrl || `${MQA_METRICS_BASE}/${portalId}/metrics`}`);
4648
+ pushLinks(lines, result);
4686
4649
  return lines.join("\n");
4687
4650
  }
4688
4651
  function registerQualityTools(server) {
@@ -4690,7 +4653,7 @@ function registerQualityTools(server) {
4690
4653
  "ckan_get_mqa_quality",
4691
4654
  {
4692
4655
  title: "Get MQA Quality Score",
4693
- description: "Get MQA (Metadata Quality Assurance) quality metrics for a dataset on dati.gov.it. Returns quality score and detailed metrics (accessibility, reusability, interoperability, findability, contextuality) from data.europa.eu. Only works with dati.gov.it server. Typical workflow: ckan_package_show (get dataset ID) \u2192 ckan_get_mqa_quality \u2192 ckan_get_mqa_quality_details (for non-max dimensions)",
4656
+ description: "Get MQA (Metadata Quality Assessment) quality score for a dataset on dati.gov.it from data.europa.eu. Returns the final score on the 0-7.5 scale of MQA methodology v2 with its band (Sufficient/Good/Excellent), dataset, distribution and data service scores, and the failing metrics with the largest gain. Datasets not yet re-evaluated fall back to the previous methodology (405 scale), labelled as such. Only works with dati.gov.it server. Typical workflow: ckan_package_show (get dataset ID) \u2192 ckan_get_mqa_quality \u2192 ckan_get_mqa_quality_details (full list of failing metrics)",
4694
4657
  inputSchema: z8.object({
4695
4658
  server_url: z8.string().url().describe("Base URL of dati.gov.it (e.g., https://www.dati.gov.it/opendata)"),
4696
4659
  dataset_id: z8.string().describe("Dataset ID or name"),
@@ -4741,7 +4704,7 @@ The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and on
4741
4704
  "ckan_get_mqa_quality_details",
4742
4705
  {
4743
4706
  title: "Get MQA Quality Details",
4744
- description: "Get detailed MQA (Metadata Quality Assurance) quality reasons for a dataset on dati.gov.it. Returns dimension scores, non-max reasons, and raw MQA flags from data.europa.eu. 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)",
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)",
4745
4708
  inputSchema: z8.object({
4746
4709
  server_url: z8.string().url().describe("Base URL of dati.gov.it (e.g., https://www.dati.gov.it/opendata)"),
4747
4710
  dataset_id: z8.string().describe("Dataset ID or name"),
@@ -4770,7 +4733,7 @@ The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and on
4770
4733
  };
4771
4734
  }
4772
4735
  try {
4773
- const details = await getMqaQualityDetails(server_url, dataset_id);
4736
+ const details = await getMqaQuality(server_url, dataset_id);
4774
4737
  const format = response_format || "markdown" /* MARKDOWN */;
4775
4738
  const output = format === "json" /* JSON */ ? truncateJson(details) : truncateText(addDemoFooter(formatQualityDetailsMarkdown(details, dataset_id)));
4776
4739
  return {
@@ -5237,10 +5200,10 @@ ${error instanceof Error ? error.message : String(error)}`;
5237
5200
 
5238
5201
  // src/tools/portal-discovery.ts
5239
5202
  import { z as z11 } from "zod";
5240
- import axios3 from "axios";
5203
+ import axios2 from "axios";
5241
5204
  var DATASHADES_URL = "https://datashades.info/api/portal/list";
5242
5205
  async function fetchPortals() {
5243
- const resp = await axios3.get(DATASHADES_URL, {
5206
+ const resp = await axios2.get(DATASHADES_URL, {
5244
5207
  timeout: 15e3,
5245
5208
  headers: { "User-Agent": "CKAN-MCP-Server/1.0" }
5246
5209
  });
@@ -5972,7 +5935,7 @@ var registerAllPrompts = (server) => {
5972
5935
  function createServer() {
5973
5936
  return new McpServer({
5974
5937
  name: "ckan-mcp-server",
5975
- version: "0.4.124"
5938
+ version: "0.4.125"
5976
5939
  });
5977
5940
  }
5978
5941
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.124",
3
+ "version": "0.4.125",
4
4
  "mcpName": "io.github.aborruso/ckan-mcp-server",
5
5
  "description": "MCP server for interacting with CKAN open data portals",
6
6
  "repository": {