@aborruso/ckan-mcp-server 0.4.123 → 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.
Files changed (4) hide show
  1. package/EXAMPLES.md +8 -24
  2. package/LOG.md +145 -0
  3. package/dist/index.js +353 -345
  4. package/package.json +3 -2
package/EXAMPLES.md CHANGED
@@ -160,34 +160,18 @@ 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)
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)
169
167
 
170
- **Note**: Only works with dati.gov.it datasets. Uses the `identifier` field (or falls back to `name`) to query the European MQA API.
168
+ `ckan_get_mqa_quality_details` lists every failing metric grouped by FAIR dimension (findability, accessibility, interoperability, reusability).
171
169
 
172
- ## USA Examples - data.gov
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.
173
171
 
174
- ### Search government datasets
175
- ```typescript
176
- ckan_package_search({
177
- server_url: "https://catalog.data.gov",
178
- q: "climate change",
179
- rows: 20
180
- })
181
- ```
172
+ ## USA - data.gov
182
173
 
183
- ### Datasets by tag
184
- ```typescript
185
- ckan_package_search({
186
- server_url: "https://catalog.data.gov",
187
- q: "tags:health",
188
- rows: 20
189
- })
190
- ```
174
+ `catalog.data.gov` stopped being a CKAN portal in 2025: Data.gov now serves its catalog through a different API (`api.gsa.gov/technology/datagov/v4`, key required, not CKAN-compatible). A call to it returns an error that says so. See [#540](https://github.com/ondata/ckan-mcp-server/issues/540).
191
175
 
192
176
  ## CKAN Demo Examples
193
177
 
package/LOG.md CHANGED
@@ -1,7 +1,152 @@
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
+
19
+ ## 2026-09-06
20
+
21
+ ### v0.4.124 - search that answers the question that was asked
22
+
23
+ Ships #542, #543, #544 and #545. For a caller: a query naming a place or an organisation
24
+ now returns that place's datasets instead of any dataset on the topic; `catalog.data.gov`
25
+ says where its data went instead of failing with "Unknown error"; `OR`, `AND` and `NOT`
26
+ are treated as operators rather than words to score.
27
+
28
+ ### Two gate cases for the strict pass, and one that was passing for free
29
+
30
+ The `mm=100%` pass added in #544 was verified by hand, so nothing stopped it from
31
+ regressing. Two cases now cover it: a boolean query must skip the strict pass
32
+ (`all_terms_results: null`), and a query no dataset satisfies in full must still be
33
+ answered by the fill pass (`all_terms_results: 0`, results returned).
34
+
35
+ Checking that they fail on the defects they name — which is the point of this file —
36
+ found that the second one did not. It asserted `total_results` and `all_terms_results`
37
+ but never that any result came back, so a build where the strict pass emptied the answer
38
+ passed it. `returned_min` was added: a tool can report thousands of matches and hand back
39
+ an empty list, and those are different claims. Both cases now fail on the broken build and
40
+ pass on the fixed one.
41
+
42
+ Also added `field_equals`, an exact check on any payload field, and `field_min`, a floor.
43
+ The split came from review: pinning the Milano case at exactly 327 matching datasets broke
44
+ the file's own rule that thresholds survive catalog drift, so counts that track the catalog
45
+ are floors now, while `null` and `0` stay equalities — they are behaviours, not counts.
46
+ `field_equals` also requires the field to be present, or a response that stopped emitting
47
+ `all_terms_results` would read as an explicit null and pass.
48
+
49
+ 16 cases, 16 passing.
50
+
51
+ ### #539: the window is the lever, not the score
52
+
53
+ The issue proposed IDF for the tie at 9.7 on `defibrillatori Comune di Lecce`. Prototyped
54
+ on the real 50 candidates the tool fetches, IDF alone makes it worse — the Lecce dataset
55
+ drops out of the top three, since `lecce` is rarer than `comune` in that set and the
56
+ "patrocini" datasets carry it in every field. Two other things do the work, and both are
57
+ smaller than a ranking model:
58
+
59
+ - `mm=100%`. It is on CKAN's `VALID_SOLR_PARAMETERS` and works on every portal tried
60
+ (dati.gov.it, Milano, Toronto, Zurigo); with a wrapped query it is ignored without error.
61
+ It asks Solr for datasets carrying every term, matched with Solr's own stemming across
62
+ `qf`, and changes the candidate window itself: the Lecce query returns exactly one
63
+ dataset; `qualità dell'aria Milano`, whose default top 50 contained no Milan dataset at
64
+ all, returns the Comune di Milano reports first; `incidenti stradali Palermo` returns 20,
65
+ all Palermo. The strict pass runs first and the default pass fills in when it is short;
66
+ strict candidates earn a `coverage` bonus (weight 4) shown in the breakdown.
67
+ - light stemming in the local matcher: `defibrillatore` in the tag and `defibrillatori` in
68
+ the query were strangers to the whole-word regex, and that tag score was the whole
69
+ difference. One final vowel stripped from words of five letters or more, whole-word
70
+ comparison kept. Elided articles (`dell'aria` → `dell`) join the stopwords.
71
+
72
+ Native dati.gov.it API against the MCP server, same text:
73
+
74
+ | query | native API, top 3 | MCP `find_relevant_datasets`, top 3 |
75
+ |---|---|---|
76
+ | defibrillatori Comune di Lecce | Mussolente, Desio, Lecce (679) | **Lecce 14.4**, then patrocini 9.7 |
77
+ | qualità dell'aria Milano | Marche, Campania, Sicilia (2401) | three Comune di Milano reports (327 with every term) |
78
+ | incidenti stradali Palermo | Lombardia, Palermo, Lombardia (320) | three Comune di Palermo datasets (20 with every term) |
79
+ | aria OR acqua (Milano) | 0 — dismax swallows the OR | 87, sent as `text:(aria OR acqua)` |
80
+
81
+ Cost: one extra call per ranked search, the strict pass; the default pass still runs so
82
+ `total_results` keeps its meaning, as a `rows=0` count when the strict pass already filled
83
+ the limit. Boolean and fielded queries make one call, as before. A portal that rejects
84
+ `mm` falls back to the default pass alone. The gate asserts terms and margin now (`terms_equal`, `margin_min`): Lecce leads
85
+ by ≥ 2 with terms `[defibrillatori, comune, lecce]`, and the Milano case requires a Comune
86
+ di Milano report first. 550 tests, 14/14 smoke. OpenSpec change `rank-on-full-coverage`.
87
+
88
+ ### catalog.data.gov has not been CKAN since 2025; we said otherwise in twelve places
89
+
90
+ Checking the two configured portals that answered nothing yesterday. `dati.arpae.it` is
91
+ still CKAN and still at the same address, just down most of the time — site-wide 500s,
92
+ not ours to fix (#541). `catalog.data.gov` is a different story: Data.gov replaced its
93
+ CKAN catalog with a Flask/OpenSearch application in 2025 and the CKAN-compatible
94
+ endpoints — `catalog-old.data.gov`, the `api.gsa.gov/…/v3` gateway — now all redirect to
95
+ the new host, which answers 404 to any CKAN request. The replacement API (`…/v4/search`)
96
+ needs a key, paginates with a cursor and, per GSA, will not support CKAN filter syntax.
97
+ 47 calls reached it through the public deployment since April and got
98
+ `CKAN API error (404): Unknown error`, while README, EXAMPLES, CLAUDE.md, the skill and a
99
+ tool description all listed it as a working CKAN portal (#540).
100
+
101
+ Fixed the part that is ours: `portals.json` entries can carry a `migrated` block, the
102
+ `catalog-data-gov` entry is kept and marked so the hint keeps firing, `CkanApiError` now
103
+ carries the portal URL, and `formatCkanError` returns the migration notice for such a
104
+ portal whatever the status — no status-based hint can be right for it. `ckan_status_show`
105
+ routes its error through the formatter too. Every document that presented it as CKAN now
106
+ says the opposite; the skill's country table says "NOT CKAN since 2025", which is what
107
+ stops an LLM client from trying. A v4 adapter is a separate proposal.
108
+
109
+ The gate gained a case that calls the live portal and expects the notice; 13/13.
110
+ OpenSpec change `mark-migrated-portals` on `ckan-error-hints`. 542 tests, 6 added.
111
+
112
+ Left alone: `docs/DECISIONS.md` still describes the parser probe replaced in #534 and says
113
+ configured portals skip it. Stale independently of this change; flagged, not touched.
114
+
3
115
  ## 2026-09-05
4
116
 
117
+ ### A release gate that asserts which dataset comes back
118
+
119
+ Three releases went out today, two of them to repair the one before. The verification in
120
+ between was a sequence of commands rebuilt from memory each time, so it covered something
121
+ different on each pass — and what it always covered was result counts. v0.4.122 shipped
122
+ with counts verified and the ranking broken, because "22 results" and "the right dataset
123
+ first" are different claims and only the second is what a caller asked for.
124
+
125
+ `npm run smoke` is that check as a command. Twelve known-answer cases from real telemetry
126
+ in `tests/smoke/cases.json`, each asserting which dataset must come back and carrying the
127
+ regression it guards; the runner starts the built server over HTTP, calls each tool the
128
+ way a client does, and exits non-zero on the first failure.
129
+
130
+ Checked against the defects it claims to catch: reintroducing the v0.4.121 wrapping rule
131
+ fails 4 of 12, and removing the parser probe from `ckan_find_relevant_datasets` — the
132
+ v0.4.122 regression — fails the case requiring the two search tools to agree. Wired into
133
+ the release workflow in `CLAUDE.md` (step 2, before the tag) and the checklist in
134
+ `docs/DEPLOYMENT.md`.
135
+
136
+ Two things the gate needed on the way:
137
+
138
+ - the JSON format never exposed the query that actually ran, while Markdown has always
139
+ shown it. `effective_query` now appears in `ckan_package_search` JSON output when the
140
+ server rewrote the query, and is absent when it did not.
141
+ - relevance scores summed unrounded fractions, printing totals like `8.299999999999999`.
142
+
143
+ Known and not fixed: the ranking model weights every query term equally, so on
144
+ `defibrillatori Comune di Lecce` three datasets tie at 9.7 and the right one leads on
145
+ Solr order rather than on score. Making the `tags` field proportional like the others was
146
+ tried and reverted — it promotes "Elenco patrocini Comune di Lecce", whose tags carry two
147
+ of the three terms against the defibrillator dataset's one. The fix is term specificity,
148
+ a design change, not a patch.
149
+
5
150
  ### v0.4.123 - relevance scoring and shared parser probe
6
151
 
7
152
  Ships #536: field scoring by share of matched terms, Italian stopwords with acronyms preserved, Unicode-aware term matching, a wider candidate window, the parser probe shared with `ckan_find_relevant_datasets`, and accent-safe filters in `ckan_organization_search` and `ckan_tag_list`. `openspec/specs/ckan-search/spec.md` rewritten around the query-building path.
package/dist/index.js CHANGED
@@ -80,7 +80,11 @@ var portals_default = {
80
80
  api_url: "https://catalog.data.gov",
81
81
  api_url_aliases: [
82
82
  "http://catalog.data.gov"
83
- ]
83
+ ],
84
+ migrated: {
85
+ notice: "catalog.data.gov stopped being a CKAN portal in 2025. Data.gov now serves its catalog through a different API (api.gsa.gov/technology/datagov/v4, key required, not CKAN-compatible), which this server does not speak.",
86
+ docs_url: "https://resources.data.gov/catalog-api/"
87
+ }
84
88
  },
85
89
  {
86
90
  id: "open-canada",
@@ -193,6 +197,14 @@ function getPortalConfig(serverUrl) {
193
197
  });
194
198
  return portal || null;
195
199
  }
200
+ function getPortalMigration(serverUrl) {
201
+ const hostname = extractHostname(serverUrl);
202
+ if (!hostname) return null;
203
+ const portal = portals_default.portals.find(
204
+ (p) => [p.api_url, ...p.api_url_aliases || []].some((url) => extractHostname(url) === hostname)
205
+ );
206
+ return portal?.migrated ?? null;
207
+ }
196
208
  function getPortalSearchConfig(serverUrl) {
197
209
  const portal = getPortalConfig(serverUrl);
198
210
  const defaults = portals_default.defaults?.search || {};
@@ -454,18 +466,26 @@ function getRateLimiter() {
454
466
  var CkanApiError = class extends Error {
455
467
  status;
456
468
  action;
457
- constructor(message, status, action) {
469
+ /** The portal the request went to, as the caller wrote it: lets the hint know its origin. */
470
+ serverUrl;
471
+ constructor(message, status, action, serverUrl) {
458
472
  super(message);
459
473
  this.name = "CkanApiError";
460
474
  this.status = status;
461
475
  this.action = action;
476
+ this.serverUrl = serverUrl;
462
477
  }
463
478
  };
464
479
  function formatCkanError(error, _toolName) {
465
480
  if (!(error instanceof CkanApiError)) {
466
481
  return error instanceof Error ? error.message : String(error);
467
482
  }
468
- const { status, action, message } = error;
483
+ const { status, action, message, serverUrl } = error;
484
+ const migration = serverUrl ? getPortalMigration(serverUrl) : null;
485
+ if (migration) {
486
+ return `${message}
487
+ \u2192 ${migration.notice} See ${migration.docs_url}`;
488
+ }
469
489
  let hint = "";
470
490
  if (status === 404) {
471
491
  if (action === "datastore_search_sql") {
@@ -972,15 +992,15 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
972
992
  clearTimeout(timeoutId);
973
993
  }
974
994
  if (!response.ok) {
975
- throw new CkanApiError(`CKAN API error (${response.status}): ${response.statusText}`, response.status, action);
995
+ throw new CkanApiError(`CKAN API error (${response.status}): ${response.statusText}`, response.status, action, serverUrl);
976
996
  }
977
997
  const declaredLength = Number(response.headers.get("content-length"));
978
998
  if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
979
- throw new CkanApiError(`Response too large (${declaredLength} bytes)`, void 0, action);
999
+ throw new CkanApiError(`Response too large (${declaredLength} bytes)`, void 0, action, serverUrl);
980
1000
  }
981
1001
  const buffer = await response.arrayBuffer();
982
1002
  if (buffer.byteLength > MAX_RESPONSE_BYTES) {
983
- throw new CkanApiError(`Response too large (${buffer.byteLength} bytes)`, void 0, action);
1003
+ throw new CkanApiError(`Response too large (${buffer.byteLength} bytes)`, void 0, action, serverUrl);
984
1004
  }
985
1005
  const headers = {};
986
1006
  response.headers.forEach((headerValue, headerKey) => {
@@ -1014,7 +1034,8 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
1014
1034
  throw new CkanApiError(
1015
1035
  `CKAN API returned success=false for action "${action}".`,
1016
1036
  void 0,
1017
- action
1037
+ action,
1038
+ serverUrl
1018
1039
  );
1019
1040
  }
1020
1041
  } catch (error) {
@@ -1025,7 +1046,7 @@ async function makeCkanRequest(serverUrl, action, params = {}, opts = {}) {
1025
1046
  const status = axiosError.response.status;
1026
1047
  const data = axiosError.response.data;
1027
1048
  const errorMsg = data?.error?.message || data?.error || "Unknown error";
1028
- throw new CkanApiError(`CKAN API error (${status}): ${errorMsg}`, status, action);
1049
+ throw new CkanApiError(`CKAN API error (${status}): ${errorMsg}`, status, action, serverUrl);
1029
1050
  } else if (axiosError.code === "ECONNABORTED") {
1030
1051
  throw new Error(`Request timeout connecting to ${serverUrl}`);
1031
1052
  } else if (axiosError.code === "ENOTFOUND") {
@@ -1359,6 +1380,7 @@ async function probePortalParser(serverUrl) {
1359
1380
  return needsText;
1360
1381
  }
1361
1382
  var DEFAULT_RELEVANCE_WEIGHTS = {
1383
+ coverage: 4,
1362
1384
  title: 4,
1363
1385
  notes: 2,
1364
1386
  tags: 3,
@@ -1376,6 +1398,14 @@ var QUERY_STOPWORDS = /* @__PURE__ */ new Set([
1376
1398
  "dei",
1377
1399
  "degli",
1378
1400
  "delle",
1401
+ // elided forms: `dell'aria` tokenises to `dell` + `aria`
1402
+ "dell",
1403
+ "nell",
1404
+ "dall",
1405
+ "sull",
1406
+ "all",
1407
+ "coll",
1408
+ "quell",
1379
1409
  "il",
1380
1410
  "lo",
1381
1411
  "la",
@@ -1444,9 +1474,16 @@ var QUERY_STOPWORDS = /* @__PURE__ */ new Set([
1444
1474
  "these",
1445
1475
  "those"
1446
1476
  ]);
1477
+ var SOLR_OPERATORS = /* @__PURE__ */ new Set(["AND", "OR", "NOT"]);
1447
1478
  var extractQueryTerms = (query) => {
1448
- const raw = query.normalize("NFC").match(/[\p{L}\p{N}]+/gu) ?? [];
1479
+ const normalized = query.normalize("NFC");
1480
+ const raw = normalized.match(/[\p{L}\p{N}]+/gu) ?? [];
1481
+ const quoted = /* @__PURE__ */ new Set();
1482
+ for (const m of normalized.matchAll(/"((?:[^"\\]|\\.)*)"/g)) {
1483
+ for (const t of m[1].match(/[\p{L}\p{N}]+/gu) ?? []) quoted.add(t);
1484
+ }
1449
1485
  const terms = raw.filter((token) => {
1486
+ if (SOLR_OPERATORS.has(token) && !quoted.has(token)) return false;
1450
1487
  const term = token.toLowerCase();
1451
1488
  if (term.length <= 1) return false;
1452
1489
  if (!QUERY_STOPWORDS.has(term)) return true;
@@ -1454,18 +1491,15 @@ var extractQueryTerms = (query) => {
1454
1491
  }).map((token) => token.toLowerCase());
1455
1492
  return Array.from(new Set(terms));
1456
1493
  };
1457
- var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1458
- var termPattern = (term) => new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(term.normalize("NFC"))}(?![\\p{L}\\p{N}])`, "iu");
1459
- var normalizeForMatch = (text) => text.normalize("NFC").toLowerCase().replace(/_/g, " ");
1460
- var textMatchesTerms = (text, terms) => {
1461
- if (!text || terms.length === 0) return false;
1462
- const normalized = normalizeForMatch(text);
1463
- return terms.some((term) => termPattern(term).test(normalized));
1464
- };
1494
+ var FINAL_VOWEL = /[aeiouàèéìòù]$/u;
1495
+ var stemTerm = (word) => word.length >= 5 ? word.replace(FINAL_VOWEL, "") : word;
1496
+ var wordsOf = (text) => text.normalize("NFC").toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
1497
+ var termMatchesWord = (term, word) => word === term || term.length >= 5 && word.length >= 5 && stemTerm(word) === stemTerm(term);
1498
+ var textMatchesTerms = (text, terms) => countMatchingTerms(text, terms) > 0;
1465
1499
  var countMatchingTerms = (text, terms) => {
1466
1500
  if (!text || terms.length === 0) return 0;
1467
- const normalized = normalizeForMatch(text);
1468
- return terms.filter((term) => termPattern(term).test(normalized)).length;
1501
+ const words = wordsOf(text);
1502
+ return terms.filter((term) => words.some((word) => termMatchesWord(term.normalize("NFC"), word))).length;
1469
1503
  };
1470
1504
  var scoreTextField = (text, terms, weight) => {
1471
1505
  const matched = countMatchingTerms(text, terms);
@@ -1483,7 +1517,7 @@ var readDcatExtra = (dataset, key) => {
1483
1517
  const rootValue = dataset[key];
1484
1518
  return typeof rootValue === "string" ? rootValue : "";
1485
1519
  };
1486
- var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS) => {
1520
+ var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS, fullCoverage = false) => {
1487
1521
  const terms = extractQueryTerms(query);
1488
1522
  const titleText = dataset.title || dataset.name || "";
1489
1523
  const notesText = dataset.notes || "";
@@ -1491,6 +1525,7 @@ var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS
1491
1525
  const holderText = readDcatExtra(dataset, "holder_name");
1492
1526
  const publisherText = readDcatExtra(dataset, "publisher_name");
1493
1527
  const breakdown = {
1528
+ coverage: fullCoverage ? weights.coverage : 0,
1494
1529
  title: scoreTextField(titleText, terms, weights.title),
1495
1530
  notes: scoreTextField(notesText, terms, weights.notes),
1496
1531
  tags: 0,
@@ -1506,7 +1541,9 @@ var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS
1506
1541
  });
1507
1542
  breakdown.tags = tagMatch ? weights.tags : 0;
1508
1543
  }
1509
- breakdown.total = breakdown.title + breakdown.notes + breakdown.tags + breakdown.organization + breakdown.holder + breakdown.publisher;
1544
+ breakdown.total = Math.round(
1545
+ (breakdown.coverage + breakdown.title + breakdown.notes + breakdown.tags + breakdown.organization + breakdown.holder + breakdown.publisher) * 10
1546
+ ) / 10;
1510
1547
  return { total: breakdown.total, breakdown, terms };
1511
1548
  };
1512
1549
  var parseAccessServices = (resource) => {
@@ -1804,9 +1841,10 @@ function normalizePackage(pkg) {
1804
1841
  }
1805
1842
  return pkg;
1806
1843
  }
1807
- function compactSearchResult(result, serverUrl) {
1844
+ function compactSearchResult(result, serverUrl, effectiveQuery) {
1808
1845
  return {
1809
1846
  count: result.count,
1847
+ ...effectiveQuery ? { effective_query: effectiveQuery } : {},
1810
1848
  results: (result.results || []).map((rawPkg) => {
1811
1849
  const pkg = serverUrl && requiresMultilingualNormalization(serverUrl) ? normalizePackage(rawPkg) : rawPkg;
1812
1850
  return {
@@ -2084,7 +2122,11 @@ Typical workflow: ckan_status_show (check locale) \u2192 ckan_package_search (qu
2084
2122
  }
2085
2123
  }
2086
2124
  if (params.response_format === "json" /* JSON */) {
2087
- const compact = compactSearchResult(result, params.server_url);
2125
+ const compact = compactSearchResult(
2126
+ result,
2127
+ params.server_url,
2128
+ effectiveQuery !== params.q ? effectiveQuery : void 0
2129
+ );
2088
2130
  return {
2089
2131
  content: [{ type: "text", text: truncateJson(compact) }]
2090
2132
  };
@@ -2244,7 +2286,9 @@ Args:
2244
2286
  - query (string): Natural language or keyword query (e.g., "mobilit\xE0 urbana", "air quality")
2245
2287
  - limit (number): Number of datasets to return (default: 10)
2246
2288
  - weights (object): Field weights for scoring \u2014 higher weight = more influence on rank
2247
- Default: title=4, tags=3, notes=2, organization=1, holder=4, publisher=2
2289
+ Default: title=4, tags=3, notes=2, organization=1, holder=4, publisher=2, coverage=4
2290
+ coverage: bonus for datasets the portal returned with every query term required
2291
+ (Solr mm=100%); these are fetched first, the rest fills in when they are fewer than limit
2248
2292
  Note on holder vs organization: on federated catalogs (e.g. dati.gov.it), \`organization\`
2249
2293
  is the harvesting catalog (e.g. Regione Puglia), while \`holder\` (DCAT-AP_IT dct:rightsHolder)
2250
2294
  is the actual data owner (e.g. Comune di Lecce). Queries like "datasets from a specific Comune"
@@ -2273,7 +2317,8 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2273
2317
  tags: z2.coerce.number().min(0).optional().describe("Weight for tag match (default 3)"),
2274
2318
  organization: z2.coerce.number().min(0).optional().describe("Weight for organization (CKAN catalog / harvester) match (default 1)"),
2275
2319
  holder: z2.coerce.number().min(0).optional().describe("Weight for holder_name match \u2014 DCAT-AP_IT dct:rightsHolder, the actual data owner (default 4)"),
2276
- publisher: z2.coerce.number().min(0).optional().describe("Weight for publisher_name match \u2014 DCAT-AP_IT dct:publisher (default 2)")
2320
+ publisher: z2.coerce.number().min(0).optional().describe("Weight for publisher_name match \u2014 DCAT-AP_IT dct:publisher (default 2)"),
2321
+ coverage: z2.coerce.number().min(0).optional().describe("Bonus for datasets the portal returned with every query term required (default 4)")
2277
2322
  }).optional().describe("Per-field scoring weights; unspecified fields use defaults"),
2278
2323
  query_parser: z2.enum(["default", "text"]).optional().describe("Override search parser ('text' forces text:(...) on non-fielded queries)"),
2279
2324
  response_format: ResponseFormatSchema
@@ -2302,27 +2347,22 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2302
2347
  params.query,
2303
2348
  parserOverride
2304
2349
  );
2305
- const searchResult = await makeCkanRequest(
2306
- params.server_url,
2307
- "package_search",
2308
- {
2309
- q: effectiveQuery,
2310
- rows,
2311
- start: 0
2312
- }
2313
- );
2314
- const scored = (searchResult.results || []).map((dataset) => {
2315
- const { total, breakdown } = scoreDatasetRelevance(
2316
- params.query,
2317
- dataset,
2318
- weights
2319
- );
2320
- return {
2321
- dataset,
2322
- score: total,
2323
- breakdown
2324
- };
2325
- });
2350
+ const strictEligible = !effectiveQuery.includes(":") && !hasExplicitBooleanOperator(params.query);
2351
+ const fetchCandidates = (extra) => makeCkanRequest(params.server_url, "package_search", { q: effectiveQuery, rows, start: 0, ...extra });
2352
+ const strictResult = strictEligible ? await fetchCandidates({ mm: "100%" }).catch(() => null) : null;
2353
+ const strictHits = strictResult?.results ?? [];
2354
+ const fillResult = await fetchCandidates(strictHits.length >= params.limit ? { rows: 0 } : {});
2355
+ const seen = new Set(strictHits.map((d) => d.id));
2356
+ const fillHits = (fillResult?.results ?? []).filter((d) => !seen.has(d.id));
2357
+ const scoreOne = (dataset, fullCoverage) => {
2358
+ const { total, breakdown } = scoreDatasetRelevance(params.query, dataset, weights, fullCoverage);
2359
+ return { dataset, score: total, breakdown };
2360
+ };
2361
+ const scored = [
2362
+ ...strictHits.map((d) => scoreOne(d, true)),
2363
+ ...fillHits.map((d) => scoreOne(d, false))
2364
+ ];
2365
+ const searchResult = fillResult;
2326
2366
  scored.sort((a, b) => b.score - a.score);
2327
2367
  const top = scored.slice(0, params.limit).map((item) => {
2328
2368
  const dataset = item.dataset;
@@ -2342,6 +2382,7 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2342
2382
  terms: extractQueryTerms(params.query),
2343
2383
  weights,
2344
2384
  total_results: searchResult.count ?? 0,
2385
+ all_terms_results: strictResult ? strictResult.count ?? 0 : null,
2345
2386
  returned: top.length,
2346
2387
  results: top
2347
2388
  };
@@ -2412,6 +2453,8 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2412
2453
  markdown += `- Organization: ${dataset.breakdown.organization}
2413
2454
  `;
2414
2455
  markdown += `- Holder: ${dataset.breakdown.holder}
2456
+ `;
2457
+ markdown += `- Coverage (every term, per the portal): ${dataset.breakdown.coverage}
2415
2458
  `;
2416
2459
  markdown += `- Publisher: ${dataset.breakdown.publisher}
2417
2460
  `;
@@ -3024,7 +3067,7 @@ Returns:
3024
3067
 
3025
3068
  Examples:
3026
3069
  - { server_url: "https://www.dati.gov.it/opendata", pattern: "toscana" }
3027
- - { server_url: "https://catalog.data.gov", pattern: "health" }
3070
+ - { server_url: "https://open.canada.ca/data", pattern: "health" }
3028
3071
 
3029
3072
  Typical workflow: ckan_organization_search \u2192 ckan_organization_show (get details) \u2192 ckan_package_search with fq="organization:name"`,
3030
3073
  inputSchema: z3.object({
@@ -3457,11 +3500,13 @@ Typical workflow: ckan_status_show (verify server is up) \u2192 ckan_package_sea
3457
3500
  structuredContent: cappedStructured(result)
3458
3501
  };
3459
3502
  } catch (error) {
3503
+ const migrated = error instanceof CkanApiError && error.serverUrl && getPortalMigration(error.serverUrl);
3504
+ const detail = formatCkanError(error, "ckan_status_show");
3460
3505
  return {
3461
3506
  content: [{
3462
3507
  type: "text",
3463
- text: `Server appears to be offline or not a valid CKAN instance:
3464
- ${error instanceof Error ? error.message : String(error)}`
3508
+ text: migrated ? detail : `Server appears to be offline or not a valid CKAN instance:
3509
+ ${detail}`
3465
3510
  }],
3466
3511
  isError: true
3467
3512
  };
@@ -4016,7 +4061,6 @@ Typical workflow: ckan_group_search \u2192 ckan_group_show (get details) \u2192
4016
4061
 
4017
4062
  // src/tools/quality.ts
4018
4063
  import { z as z8 } from "zod";
4019
- import axios2 from "axios";
4020
4064
  var MQA_API_BASE = "https://data.europa.eu/api/mqa/cache/datasets";
4021
4065
  var MQA_METRICS_BASE = "https://data.europa.eu/api/hub/repo/datasets";
4022
4066
  var ALLOWED_MQA_HOSTS = /* @__PURE__ */ new Set(["dati.gov.it", "www.dati.gov.it"]);
@@ -4306,338 +4350,302 @@ function findNonMaxDimensions(scores) {
4306
4350
  });
4307
4351
  return nonMax;
4308
4352
  }
4309
- async function fetchMqaQuality(serverUrl, datasetId) {
4310
- const dataset = await makeCkanRequest(
4311
- serverUrl,
4312
- "package_show",
4313
- { id: datasetId }
4314
- );
4315
- const baseIdentifier = dataset.identifier || dataset.name;
4316
- const candidates = buildMqaIdCandidates(baseIdentifier);
4317
- if (candidates.length === 0) {
4318
- throw new Error("Dataset identifier is empty; cannot query MQA API");
4319
- }
4320
- for (const europeanId of candidates) {
4321
- const mqaUrl = `${MQA_API_BASE}/${europeanId}`;
4322
- const metricsUrl = `${MQA_METRICS_BASE}/${europeanId}/metrics`;
4323
- try {
4324
- const response = await axios2.get(mqaUrl, {
4325
- timeout: 3e4,
4326
- headers: {
4327
- "User-Agent": "CKAN-MCP-Server/1.0"
4328
- }
4329
- });
4330
- let metricsPayload;
4331
- try {
4332
- const metricsResponse = await safeFetch(metricsUrl, {
4333
- headers: {
4334
- "User-Agent": "CKAN-MCP-Server/1.0"
4335
- }
4336
- }, { httpsOnly: true });
4337
- if (!metricsResponse.ok) {
4338
- throw new Error(`MQA metrics error: ${metricsResponse.status} ${metricsResponse.statusText}`);
4339
- }
4340
- try {
4341
- metricsPayload = await metricsResponse.json();
4342
- } catch {
4343
- metricsPayload = await metricsResponse.text();
4344
- }
4345
- } catch (metricsError) {
4346
- if (metricsError instanceof Error) {
4347
- throw new Error(`MQA metrics error: ${metricsError.message}`);
4348
- }
4349
- throw metricsError;
4350
- }
4351
- const scores = extractMetricsScores(metricsPayload);
4352
- const resultEntry = response.data?.result?.results?.[0];
4353
- const portalId = resultEntry?.info?.["dataset-id"] || europeanId;
4354
- const breakdown = {
4355
- scores,
4356
- nonMaxDimensions: findNonMaxDimensions(scores),
4357
- metricsUrl,
4358
- mqaUrl,
4359
- portalId
4360
- };
4361
- return {
4362
- mqa: response.data,
4363
- metrics: metricsPayload,
4364
- breakdown
4365
- };
4366
- } catch (error) {
4367
- if (axios2.isAxiosError(error)) {
4368
- if (error.response?.status === 404) {
4369
- continue;
4370
- }
4371
- throw new Error(`MQA API error: ${error.message}`);
4372
- }
4373
- 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);
4374
4368
  }
4375
4369
  }
4376
- throw new Error(
4377
- `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').`
4378
- );
4370
+ return uri;
4379
4371
  }
4380
- async function getMqaQuality(serverUrl, datasetId) {
4381
- const result = await fetchMqaQuality(serverUrl, datasetId);
4382
- return {
4383
- mqa: result.mqa,
4384
- breakdown: result.breakdown
4385
- };
4372
+ function mqaBand(score) {
4373
+ if (score >= 5) return "Excellent";
4374
+ if (score >= 2.5) return "Good";
4375
+ return "Sufficient";
4386
4376
  }
4387
- async function getMqaQualityDetails(serverUrl, datasetId) {
4388
- const result = await fetchMqaQuality(serverUrl, datasetId);
4389
- const details = extractMetricDetails(result.metrics, result.breakdown.nonMaxDimensions);
4390
- return {
4391
- breakdown: result.breakdown,
4392
- details
4393
- };
4377
+ function round(value, digits = 3) {
4378
+ const factor = 10 ** digits;
4379
+ return Math.round(value * factor) / factor;
4394
4380
  }
4395
- function findSectionMetric(section, key) {
4396
- if (!Array.isArray(section)) {
4397
- return void 0;
4398
- }
4399
- for (const item of section) {
4400
- if (item && typeof item === "object" && key in item) {
4401
- return item[key];
4402
- }
4381
+ function groupScore(entities) {
4382
+ if (entities.length === 0) {
4383
+ return null;
4403
4384
  }
4404
- return void 0;
4385
+ const average = entities.reduce((sum, entity) => sum + entity.score, 0) / entities.length;
4386
+ return { count: entities.length, average: round(average) };
4405
4387
  }
4406
- function metricArrayIsAvailable(metric) {
4407
- if (!Array.isArray(metric)) {
4408
- 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");
4409
4392
  }
4410
- const byName = /* @__PURE__ */ new Map();
4411
- for (const entry of metric) {
4412
- if (!entry || typeof entry !== "object") {
4413
- continue;
4414
- }
4415
- const name = entry.name;
4416
- const percentage = entry.percentage;
4417
- if (typeof name === "string" && typeof percentage === "number") {
4418
- byName.set(name.toLowerCase(), percentage);
4419
- }
4420
- }
4421
- if (byName.has("yes")) {
4422
- return (byName.get("yes") || 0) > 0;
4423
- }
4424
- if (byName.size > 0) {
4425
- for (const [name, percentage] of byName.entries()) {
4426
- if (name.startsWith("2") && percentage > 0) {
4427
- 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
+ }
4428
4415
  }
4429
4416
  }
4430
- 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
+ }
4431
4431
  }
4432
- 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
+ };
4433
4445
  }
4434
- function metricBoolean(section, key) {
4435
- const metric = findSectionMetric(section, key);
4436
- if (typeof metric === "boolean") {
4437
- return metric;
4438
- }
4439
- 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 });
4440
4452
  }
4441
- function metricAvailability(section, availabilityKey, statusKey) {
4442
- const availabilityMetric = findSectionMetric(section, availabilityKey);
4443
- const availability = metricArrayIsAvailable(availabilityMetric);
4444
- if (availability !== void 0) {
4445
- return { available: availability };
4446
- }
4447
- if (statusKey) {
4448
- const statusMetric = findSectionMetric(section, statusKey);
4449
- const statusAvailable = metricArrayIsAvailable(statusMetric);
4450
- if (statusAvailable !== void 0) {
4451
- 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}`);
4452
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)}`);
4453
4466
  }
4454
- return void 0;
4455
4467
  }
4456
- function normalizeQualityData(data) {
4457
- const d = data;
4458
- const mqaData = d?.mqa ?? data;
4459
- const breakdown = d?.breakdown;
4460
- const resultEntry = mqaData?.result?.results?.[0];
4461
- if (!resultEntry || typeof resultEntry !== "object") {
4462
- 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.`);
4463
4481
  }
4482
+ const nonMaxDimensions = findNonMaxDimensions(scores);
4464
4483
  return {
4465
- id: resultEntry.info?.["dataset-id"],
4466
- info: { score: resultEntry.info?.score },
4467
- accessibility: {
4468
- accessUrl: metricAvailability(resultEntry.accessibility, "accessUrlAvailability", "accessUrlStatusCode"),
4469
- downloadUrl: metricAvailability(resultEntry.accessibility, "downloadUrlAvailability", "downloadUrlStatusCode")
4470
- },
4471
- reusability: {
4472
- licence: metricAvailability(resultEntry.reusability, "licenceAvailability"),
4473
- contactPoint: metricBoolean(resultEntry.reusability, "contactPointAvailability") !== void 0 ? { available: metricBoolean(resultEntry.reusability, "contactPointAvailability") } : void 0,
4474
- publisher: metricBoolean(resultEntry.reusability, "publisherAvailability") !== void 0 ? { available: metricBoolean(resultEntry.reusability, "publisherAvailability") } : void 0
4475
- },
4476
- interoperability: {
4477
- format: metricAvailability(resultEntry.interoperability, "formatAvailability"),
4478
- 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
4479
4495
  },
4480
- findability: {
4481
- keyword: metricBoolean(resultEntry.findability, "keywordAvailability") !== void 0 ? { available: metricBoolean(resultEntry.findability, "keywordAvailability") } : void 0,
4482
- category: metricBoolean(resultEntry.findability, "categoryAvailability") !== void 0 ? { available: metricBoolean(resultEntry.findability, "categoryAvailability") } : void 0,
4483
- spatial: metricBoolean(resultEntry.findability, "spatialAvailability") !== void 0 ? { available: metricBoolean(resultEntry.findability, "spatialAvailability") } : void 0,
4484
- temporal: metricBoolean(resultEntry.findability, "temporalAvailability") !== void 0 ? { available: metricBoolean(resultEntry.findability, "temporalAvailability") } : void 0
4485
- },
4486
- contextuality: {
4487
- byteSize: metricAvailability(resultEntry.contextuality, "byteSizeAvailability"),
4488
- rights: metricAvailability(resultEntry.contextuality, "rightsAvailability")
4489
- },
4490
- breakdown
4496
+ details: extractMetricDetails(metrics, nonMaxDimensions),
4497
+ ...links
4491
4498
  };
4492
4499
  }
4493
- function formatQualityMarkdown(data, datasetId) {
4494
- const normalized = normalizeQualityData(data);
4495
- const lines = [];
4496
- lines.push(`# Quality Metrics for Dataset: ${datasetId}`);
4497
- lines.push("");
4498
- if (normalized.info?.score !== void 0) {
4499
- lines.push(`**Overall Score**: ${normalized.info.score}/405`);
4500
- 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");
4501
4510
  }
4502
- if (normalized.breakdown?.scores) {
4503
- lines.push("## Dimension Scores");
4504
- const scores = normalized.breakdown.scores;
4505
- const order = [
4506
- ["accessibility", "Accessibility", DIMENSION_MAX.accessibility],
4507
- ["findability", "Findability", DIMENSION_MAX.findability],
4508
- ["interoperability", "Interoperability", DIMENSION_MAX.interoperability],
4509
- ["reusability", "Reusability", DIMENSION_MAX.reusability],
4510
- ["contextuality", "Contextuality", DIMENSION_MAX.contextuality]
4511
- ];
4512
- for (const [key, label, max] of order) {
4513
- const value = scores[key];
4514
- if (typeof value === "number") {
4515
- const isMax = value >= max;
4516
- const status = isMax ? "\u2705" : "\u26A0\uFE0F";
4517
- lines.push(`- ${label}: ${value}/${max} ${status}${isMax ? "" : ` (max ${max})`}`);
4518
- }
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)}`);
4519
4522
  }
4520
- if (normalized.breakdown.nonMaxDimensions.length > 0) {
4521
- lines.push(`- Non-max dimension(s): ${normalized.breakdown.nonMaxDimensions.join(", ")}`);
4522
- } else if (Object.keys(scores).length > 0) {
4523
- 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;
4524
4528
  }
4525
- lines.push("");
4526
- }
4527
- if (normalized.accessibility) {
4528
- lines.push("## Accessibility");
4529
- if (normalized.accessibility.accessUrl !== void 0) {
4530
- 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}`);
4531
4531
  }
4532
- if (normalized.accessibility.downloadUrl !== void 0) {
4533
- 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");
4534
4536
  }
4535
- lines.push("");
4537
+ return parseMqaV2(entry, links);
4536
4538
  }
4537
- if (normalized.reusability) {
4538
- lines.push("## Reusability");
4539
- if (normalized.reusability.licence !== void 0) {
4540
- lines.push(`- License: ${normalized.reusability.licence.available ? "\u2713" : "\u2717"} Available`);
4541
- }
4542
- if (normalized.reusability.contactPoint !== void 0) {
4543
- lines.push(`- Contact Point: ${normalized.reusability.contactPoint.available ? "\u2713" : "\u2717"} Available`);
4544
- }
4545
- if (normalized.reusability.publisher !== void 0) {
4546
- lines.push(`- Publisher: ${normalized.reusability.publisher.available ? "\u2713" : "\u2717"} Available`);
4547
- }
4548
- 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}`);
4549
4558
  }
4550
- if (normalized.interoperability) {
4551
- lines.push("## Interoperability");
4552
- if (normalized.interoperability.format !== void 0) {
4553
- lines.push(`- Format: ${normalized.interoperability.format.available ? "\u2713" : "\u2717"} Available`);
4554
- }
4555
- if (normalized.interoperability.mediaType !== void 0) {
4556
- lines.push(`- Media Type: ${normalized.interoperability.mediaType.available ? "\u2713" : "\u2717"} Available`);
4557
- }
4558
- lines.push("");
4559
+ if (result.dataServices) {
4560
+ lines.push(`- Data services (${result.dataServices.count}): average ${formatNumber(result.dataServices.average)}/${result.maxScore}`);
4559
4561
  }
4560
- if (normalized.findability) {
4561
- lines.push("## Findability");
4562
- if (normalized.findability.keyword !== void 0) {
4563
- lines.push(`- Keywords: ${normalized.findability.keyword.available ? "\u2713" : "\u2717"} Available`);
4564
- }
4565
- if (normalized.findability.category !== void 0) {
4566
- lines.push(`- Category: ${normalized.findability.category.available ? "\u2713" : "\u2717"} Available`);
4567
- }
4568
- if (normalized.findability.spatial !== void 0) {
4569
- lines.push(`- Spatial: ${normalized.findability.spatial.available ? "\u2713" : "\u2717"} Available`);
4570
- }
4571
- if (normalized.findability.temporal !== void 0) {
4572
- lines.push(`- Temporal: ${normalized.findability.temporal.available ? "\u2713" : "\u2717"} Available`);
4573
- }
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}`);
4574
4570
  lines.push("");
4575
4571
  }
4576
- if (normalized.contextuality) {
4577
- lines.push("## Contextuality");
4578
- if (normalized.contextuality.byteSize !== void 0) {
4579
- 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("");
4580
4598
  }
4581
- if (normalized.contextuality.rights !== void 0) {
4582
- 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
+ }
4583
4611
  }
4584
4612
  lines.push("");
4585
4613
  }
4586
- lines.push("---");
4587
- const portalId = normalized.breakdown?.portalId || normalized.id || datasetId;
4588
- lines.push(`Portal: https://data.europa.eu/data/datasets/${portalId}/quality?locale=it`);
4589
- lines.push(`MQA source: ${MQA_API_BASE}/${portalId}`);
4590
- const metricsEndpoint = normalized.breakdown?.metricsUrl || `${MQA_METRICS_BASE}/${portalId}/metrics`;
4591
- lines.push(`Metrics endpoint: ${metricsEndpoint}`);
4592
- lines.push(`Tip: Use the metrics endpoint to explain score deductions (e.g., failing measurements such as knownLicence = false).`);
4614
+ pushLinks(lines, result);
4593
4615
  return lines.join("\n");
4594
4616
  }
4595
- function formatQualityDetailsMarkdown(data, datasetId) {
4596
- const lines = [];
4597
- const breakdown = data.breakdown;
4598
- lines.push(`# Quality Details for Dataset: ${datasetId}`);
4599
- lines.push("");
4600
- if (typeof breakdown.scores.total === "number") {
4601
- lines.push(`**Overall Score**: ${breakdown.scores.total}/405`);
4602
- lines.push("");
4603
- }
4604
- if (breakdown.scores) {
4605
- lines.push("## Dimension Scores");
4606
- const order = [
4607
- ["accessibility", DIMENSION_MAX.accessibility],
4608
- ["findability", DIMENSION_MAX.findability],
4609
- ["interoperability", DIMENSION_MAX.interoperability],
4610
- ["reusability", DIMENSION_MAX.reusability],
4611
- ["contextuality", DIMENSION_MAX.contextuality]
4612
- ];
4613
- for (const [key, max] of order) {
4614
- const value = breakdown.scores[key];
4615
- if (typeof value === "number") {
4616
- const status = value >= max ? "\u2705" : "\u26A0\uFE0F";
4617
- lines.push(`- ${DIMENSION_LABELS[key]}: ${value}/${max} ${status}${value >= max ? "" : ` (max ${max})`}`);
4618
- }
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"}`);
4619
4628
  }
4620
4629
  lines.push("");
4621
- }
4622
- lines.push("## Non-max Reasons");
4623
- if (breakdown.nonMaxDimensions.length === 0) {
4624
- lines.push("- All dimensions are at max score.");
4625
4630
  } else {
4626
- for (const dimension of breakdown.nonMaxDimensions) {
4627
- const reasons = data.details.reasons[dimension] || [];
4628
- if (reasons.length === 0) {
4629
- lines.push(`- ${DIMENSION_LABELS[dimension]}: no failing flags detected in metrics payload`);
4630
- } else {
4631
- 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)}`);
4632
4642
  }
4643
+ lines.push("");
4633
4644
  }
4645
+ lines.push("Gain = increase of the final score if every listed entity fulfilled the metric.");
4646
+ lines.push("");
4634
4647
  }
4635
- lines.push("");
4636
- lines.push("---");
4637
- const portalId = breakdown.portalId || datasetId;
4638
- lines.push(`Portal: https://data.europa.eu/data/datasets/${portalId}/quality?locale=it`);
4639
- lines.push(`MQA source: ${MQA_API_BASE}/${portalId}`);
4640
- lines.push(`Metrics endpoint: ${breakdown.metricsUrl || `${MQA_METRICS_BASE}/${portalId}/metrics`}`);
4648
+ pushLinks(lines, result);
4641
4649
  return lines.join("\n");
4642
4650
  }
4643
4651
  function registerQualityTools(server) {
@@ -4645,7 +4653,7 @@ function registerQualityTools(server) {
4645
4653
  "ckan_get_mqa_quality",
4646
4654
  {
4647
4655
  title: "Get MQA Quality Score",
4648
- 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)",
4649
4657
  inputSchema: z8.object({
4650
4658
  server_url: z8.string().url().describe("Base URL of dati.gov.it (e.g., https://www.dati.gov.it/opendata)"),
4651
4659
  dataset_id: z8.string().describe("Dataset ID or name"),
@@ -4696,7 +4704,7 @@ The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and on
4696
4704
  "ckan_get_mqa_quality_details",
4697
4705
  {
4698
4706
  title: "Get MQA Quality Details",
4699
- 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)",
4700
4708
  inputSchema: z8.object({
4701
4709
  server_url: z8.string().url().describe("Base URL of dati.gov.it (e.g., https://www.dati.gov.it/opendata)"),
4702
4710
  dataset_id: z8.string().describe("Dataset ID or name"),
@@ -4725,7 +4733,7 @@ The MQA (Metadata Quality Assurance) system is operated by data.europa.eu and on
4725
4733
  };
4726
4734
  }
4727
4735
  try {
4728
- const details = await getMqaQualityDetails(server_url, dataset_id);
4736
+ const details = await getMqaQuality(server_url, dataset_id);
4729
4737
  const format = response_format || "markdown" /* MARKDOWN */;
4730
4738
  const output = format === "json" /* JSON */ ? truncateJson(details) : truncateText(addDemoFooter(formatQualityDetailsMarkdown(details, dataset_id)));
4731
4739
  return {
@@ -5192,10 +5200,10 @@ ${error instanceof Error ? error.message : String(error)}`;
5192
5200
 
5193
5201
  // src/tools/portal-discovery.ts
5194
5202
  import { z as z11 } from "zod";
5195
- import axios3 from "axios";
5203
+ import axios2 from "axios";
5196
5204
  var DATASHADES_URL = "https://datashades.info/api/portal/list";
5197
5205
  async function fetchPortals() {
5198
- const resp = await axios3.get(DATASHADES_URL, {
5206
+ const resp = await axios2.get(DATASHADES_URL, {
5199
5207
  timeout: 15e3,
5200
5208
  headers: { "User-Agent": "CKAN-MCP-Server/1.0" }
5201
5209
  });
@@ -5927,7 +5935,7 @@ var registerAllPrompts = (server) => {
5927
5935
  function createServer() {
5928
5936
  return new McpServer({
5929
5937
  name: "ckan-mcp-server",
5930
- version: "0.4.123"
5938
+ version: "0.4.125"
5931
5939
  });
5932
5940
  }
5933
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.123",
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": {
@@ -28,7 +28,8 @@
28
28
  "pack:dxt": "rm -rf dxt-staging && mkdir -p dxt-staging/server && npm run build:dxt && echo '{\"type\":\"commonjs\"}' > dxt-staging/server/package.json && mkdir -p dxt-staging/server/node_modules && cp -r node_modules/undici dxt-staging/server/node_modules/ && cp manifest.json icon.png dxt-staging/ && dxt pack dxt-staging ckan-mcp-server.dxt && rm -rf dxt-staging",
29
29
  "pack:skill": "mkdir -p tmp && cd skills && zip -r ../tmp/ckan-mcp.skill ckan-mcp/ && cd ..",
30
30
  "prepack": "cp README.md .readme-full.md && cp .readme-npm.md README.md",
31
- "postpack": "mv .readme-full.md README.md"
31
+ "postpack": "mv .readme-full.md README.md",
32
+ "smoke": "npm run build && node scripts/smoke.mjs"
32
33
  },
33
34
  "keywords": [
34
35
  "mcp",