@aborruso/ckan-mcp-server 0.4.122 → 0.4.124

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 +2 -18
  2. package/LOG.md +186 -0
  3. package/dist/index.js +168 -53
  4. package/package.json +3 -2
package/EXAMPLES.md CHANGED
@@ -169,25 +169,9 @@ Returns quality score and detailed metrics from data.europa.eu MQA (Metadata Qua
169
169
 
170
170
  **Note**: Only works with dati.gov.it datasets. Uses the `identifier` field (or falls back to `name`) to query the European MQA API.
171
171
 
172
- ## USA Examples - data.gov
172
+ ## USA - data.gov
173
173
 
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
- ```
182
-
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,193 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-09-06
4
+
5
+ ### v0.4.124 - search that answers the question that was asked
6
+
7
+ Ships #542, #543, #544 and #545. For a caller: a query naming a place or an organisation
8
+ now returns that place's datasets instead of any dataset on the topic; `catalog.data.gov`
9
+ says where its data went instead of failing with "Unknown error"; `OR`, `AND` and `NOT`
10
+ are treated as operators rather than words to score.
11
+
12
+ ### Two gate cases for the strict pass, and one that was passing for free
13
+
14
+ The `mm=100%` pass added in #544 was verified by hand, so nothing stopped it from
15
+ regressing. Two cases now cover it: a boolean query must skip the strict pass
16
+ (`all_terms_results: null`), and a query no dataset satisfies in full must still be
17
+ answered by the fill pass (`all_terms_results: 0`, results returned).
18
+
19
+ Checking that they fail on the defects they name — which is the point of this file —
20
+ found that the second one did not. It asserted `total_results` and `all_terms_results`
21
+ but never that any result came back, so a build where the strict pass emptied the answer
22
+ passed it. `returned_min` was added: a tool can report thousands of matches and hand back
23
+ an empty list, and those are different claims. Both cases now fail on the broken build and
24
+ pass on the fixed one.
25
+
26
+ Also added `field_equals`, an exact check on any payload field, and `field_min`, a floor.
27
+ The split came from review: pinning the Milano case at exactly 327 matching datasets broke
28
+ the file's own rule that thresholds survive catalog drift, so counts that track the catalog
29
+ are floors now, while `null` and `0` stay equalities — they are behaviours, not counts.
30
+ `field_equals` also requires the field to be present, or a response that stopped emitting
31
+ `all_terms_results` would read as an explicit null and pass.
32
+
33
+ 16 cases, 16 passing.
34
+
35
+ ### #539: the window is the lever, not the score
36
+
37
+ The issue proposed IDF for the tie at 9.7 on `defibrillatori Comune di Lecce`. Prototyped
38
+ on the real 50 candidates the tool fetches, IDF alone makes it worse — the Lecce dataset
39
+ drops out of the top three, since `lecce` is rarer than `comune` in that set and the
40
+ "patrocini" datasets carry it in every field. Two other things do the work, and both are
41
+ smaller than a ranking model:
42
+
43
+ - `mm=100%`. It is on CKAN's `VALID_SOLR_PARAMETERS` and works on every portal tried
44
+ (dati.gov.it, Milano, Toronto, Zurigo); with a wrapped query it is ignored without error.
45
+ It asks Solr for datasets carrying every term, matched with Solr's own stemming across
46
+ `qf`, and changes the candidate window itself: the Lecce query returns exactly one
47
+ dataset; `qualità dell'aria Milano`, whose default top 50 contained no Milan dataset at
48
+ all, returns the Comune di Milano reports first; `incidenti stradali Palermo` returns 20,
49
+ all Palermo. The strict pass runs first and the default pass fills in when it is short;
50
+ strict candidates earn a `coverage` bonus (weight 4) shown in the breakdown.
51
+ - light stemming in the local matcher: `defibrillatore` in the tag and `defibrillatori` in
52
+ the query were strangers to the whole-word regex, and that tag score was the whole
53
+ difference. One final vowel stripped from words of five letters or more, whole-word
54
+ comparison kept. Elided articles (`dell'aria` → `dell`) join the stopwords.
55
+
56
+ Native dati.gov.it API against the MCP server, same text:
57
+
58
+ | query | native API, top 3 | MCP `find_relevant_datasets`, top 3 |
59
+ |---|---|---|
60
+ | defibrillatori Comune di Lecce | Mussolente, Desio, Lecce (679) | **Lecce 14.4**, then patrocini 9.7 |
61
+ | qualità dell'aria Milano | Marche, Campania, Sicilia (2401) | three Comune di Milano reports (327 with every term) |
62
+ | incidenti stradali Palermo | Lombardia, Palermo, Lombardia (320) | three Comune di Palermo datasets (20 with every term) |
63
+ | aria OR acqua (Milano) | 0 — dismax swallows the OR | 87, sent as `text:(aria OR acqua)` |
64
+
65
+ Cost: one extra call per ranked search, the strict pass; the default pass still runs so
66
+ `total_results` keeps its meaning, as a `rows=0` count when the strict pass already filled
67
+ the limit. Boolean and fielded queries make one call, as before. A portal that rejects
68
+ `mm` falls back to the default pass alone. The gate asserts terms and margin now (`terms_equal`, `margin_min`): Lecce leads
69
+ by ≥ 2 with terms `[defibrillatori, comune, lecce]`, and the Milano case requires a Comune
70
+ di Milano report first. 550 tests, 14/14 smoke. OpenSpec change `rank-on-full-coverage`.
71
+
72
+ ### catalog.data.gov has not been CKAN since 2025; we said otherwise in twelve places
73
+
74
+ Checking the two configured portals that answered nothing yesterday. `dati.arpae.it` is
75
+ still CKAN and still at the same address, just down most of the time — site-wide 500s,
76
+ not ours to fix (#541). `catalog.data.gov` is a different story: Data.gov replaced its
77
+ CKAN catalog with a Flask/OpenSearch application in 2025 and the CKAN-compatible
78
+ endpoints — `catalog-old.data.gov`, the `api.gsa.gov/…/v3` gateway — now all redirect to
79
+ the new host, which answers 404 to any CKAN request. The replacement API (`…/v4/search`)
80
+ needs a key, paginates with a cursor and, per GSA, will not support CKAN filter syntax.
81
+ 47 calls reached it through the public deployment since April and got
82
+ `CKAN API error (404): Unknown error`, while README, EXAMPLES, CLAUDE.md, the skill and a
83
+ tool description all listed it as a working CKAN portal (#540).
84
+
85
+ Fixed the part that is ours: `portals.json` entries can carry a `migrated` block, the
86
+ `catalog-data-gov` entry is kept and marked so the hint keeps firing, `CkanApiError` now
87
+ carries the portal URL, and `formatCkanError` returns the migration notice for such a
88
+ portal whatever the status — no status-based hint can be right for it. `ckan_status_show`
89
+ routes its error through the formatter too. Every document that presented it as CKAN now
90
+ says the opposite; the skill's country table says "NOT CKAN since 2025", which is what
91
+ stops an LLM client from trying. A v4 adapter is a separate proposal.
92
+
93
+ The gate gained a case that calls the live portal and expects the notice; 13/13.
94
+ OpenSpec change `mark-migrated-portals` on `ckan-error-hints`. 542 tests, 6 added.
95
+
96
+ Left alone: `docs/DECISIONS.md` still describes the parser probe replaced in #534 and says
97
+ configured portals skip it. Stale independently of this change; flagged, not touched.
98
+
3
99
  ## 2026-09-05
4
100
 
101
+ ### A release gate that asserts which dataset comes back
102
+
103
+ Three releases went out today, two of them to repair the one before. The verification in
104
+ between was a sequence of commands rebuilt from memory each time, so it covered something
105
+ different on each pass — and what it always covered was result counts. v0.4.122 shipped
106
+ with counts verified and the ranking broken, because "22 results" and "the right dataset
107
+ first" are different claims and only the second is what a caller asked for.
108
+
109
+ `npm run smoke` is that check as a command. Twelve known-answer cases from real telemetry
110
+ in `tests/smoke/cases.json`, each asserting which dataset must come back and carrying the
111
+ regression it guards; the runner starts the built server over HTTP, calls each tool the
112
+ way a client does, and exits non-zero on the first failure.
113
+
114
+ Checked against the defects it claims to catch: reintroducing the v0.4.121 wrapping rule
115
+ fails 4 of 12, and removing the parser probe from `ckan_find_relevant_datasets` — the
116
+ v0.4.122 regression — fails the case requiring the two search tools to agree. Wired into
117
+ the release workflow in `CLAUDE.md` (step 2, before the tag) and the checklist in
118
+ `docs/DEPLOYMENT.md`.
119
+
120
+ Two things the gate needed on the way:
121
+
122
+ - the JSON format never exposed the query that actually ran, while Markdown has always
123
+ shown it. `effective_query` now appears in `ckan_package_search` JSON output when the
124
+ server rewrote the query, and is absent when it did not.
125
+ - relevance scores summed unrounded fractions, printing totals like `8.299999999999999`.
126
+
127
+ Known and not fixed: the ranking model weights every query term equally, so on
128
+ `defibrillatori Comune di Lecce` three datasets tie at 9.7 and the right one leads on
129
+ Solr order rather than on score. Making the `tags` field proportional like the others was
130
+ tried and reverted — it promotes "Elenco patrocini Comune di Lecce", whose tags carry two
131
+ of the three terms against the defibrillator dataset's one. The fix is term specificity,
132
+ a design change, not a patch.
133
+
134
+ ### v0.4.123 - relevance scoring and shared parser probe
135
+
136
+ 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.
137
+
138
+ ### Relevance scoring: three defects that only became visible once search worked
139
+
140
+ Testing v0.4.122 through an MCP client, not curl, showed `ckan_find_relevant_datasets`
141
+ answering its own documented example badly. `defibrillatori Comune di Lecce` on
142
+ dati.gov.it used to return the catalog's only Lecce dataset — because the search
143
+ returned exactly one result. With recall restored it returns 679, and the top three
144
+ became Trento, Martina Franca and Desio while the Lecce dataset fell out of the window.
145
+
146
+ The wrapping fix did not cause this; it removed the cover. Three defects, all older:
147
+
148
+ - `scoreTextField` awarded the **whole** field weight when **any** query term matched.
149
+ "Comune di Martina Franca" and "Comune di Lecce" both scored a full holder match, so
150
+ the right dataset could not outrank the wrong ones. It now scores the share of terms
151
+ the field carries.
152
+ - the stopword list was English-only, so `di` counted as a term: "Provincia Autonoma
153
+ **di** Trento" earned a full holder match on a query asking for Lecce. Italian
154
+ stopwords added.
155
+ - `\b` is ASCII-only in JavaScript, so a term ending in an accented letter never found
156
+ its word boundary: `mobilità` scored 0 against "mobilità urbana", `qualità` against
157
+ "qualità dell'aria". On a catalog that is mostly not in English this silently sank
158
+ every accented query. Replaced with Unicode lookarounds.
159
+
160
+ Looking for more of the same family turned up three more, all cases of a local filter
161
+ running over a truncated or wrongly-normalised set:
162
+
163
+ - `ckan_find_relevant_datasets` never called the parser probe. `portals.json` used to
164
+ cover it; removing `force_text_field` left it sending boolean queries to the parser that
165
+ ignores them. On dati.comune.milano.it `aria OR acqua` returned 0 there against 87 from
166
+ `ckan_package_search`. Same probe now applies to both.
167
+ - `ckan_organization_search` builds a Solr wildcard, which bypasses the analysis chain, so
168
+ the pattern must be pre-normalised the way CKAN builds a name slug. It lowercased but did
169
+ not fold accents: `città` returned 0 while `citta` matched 135 datasets.
170
+ - `ckan_tag_list` applied `tag_query` after faceting, with `facet.limit` set to the
171
+ caller's `limit`. On dati.gov.it 53 tags contain "citta" and none is in the top 100, so
172
+ the filter answered "no tags" while they existed. The facet is now widened when a filter
173
+ is given.
174
+
175
+ `openspec/specs/ckan-search/spec.md` described the parser as a property of
176
+ `ckan_package_search` alone, which is what let the second caller go unnoticed — and after
177
+ yesterday it was also wrong, still describing the per-portal default that was removed.
178
+ Rewritten as a property of the query-building path, naming every tool that shares it.
179
+
180
+ Also raised the candidate window to at least 50: the local ranking only sees what Solr
181
+ returns first, and `limit: 3` shrank it to 15 — enough when a search returned a handful
182
+ of results, not enough now.
183
+
184
+ `defibrillatori Comune di Lecce` puts the Lecce dataset first again. 532 tests, 5 added.
185
+
186
+ How it was missed: yesterday's verification checked result **counts** through
187
+ `ckan_package_search`, never the ranked output of `ckan_find_relevant_datasets` — the
188
+ third most used tool in the telemetry, and the second caller of `resolveSearchQuery`.
189
+ Counting results proves recall, not usefulness.
190
+
5
191
  ### v0.4.122 - Solr parser fix
6
192
 
7
193
  Ships #534: the `text:(...)` wrapper is reserved for the queries dismax cannot serve, the escaping preserves unary operators and balanced grouping, and the parser probe measures two terms taken from the catalog on every portal. `force_text_field` is gone from `portals.json`.
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,
@@ -1367,6 +1389,49 @@ var DEFAULT_RELEVANCE_WEIGHTS = {
1367
1389
  publisher: 2
1368
1390
  };
1369
1391
  var QUERY_STOPWORDS = /* @__PURE__ */ new Set([
1392
+ // Italian: without these, `defibrillatori Comune di Lecce` scored a full holder
1393
+ // match against "Provincia Autonoma di Trento" on the strength of "di" alone.
1394
+ "di",
1395
+ "del",
1396
+ "dello",
1397
+ "della",
1398
+ "dei",
1399
+ "degli",
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",
1409
+ "il",
1410
+ "lo",
1411
+ "la",
1412
+ "i",
1413
+ "gli",
1414
+ "le",
1415
+ "un",
1416
+ "uno",
1417
+ "una",
1418
+ "e",
1419
+ "ed",
1420
+ "per",
1421
+ "con",
1422
+ "su",
1423
+ "da",
1424
+ "dal",
1425
+ "dalla",
1426
+ "nel",
1427
+ "nella",
1428
+ "al",
1429
+ "alla",
1430
+ "che",
1431
+ "non",
1432
+ "come",
1433
+ "dove",
1434
+ "sono",
1370
1435
  "a",
1371
1436
  "an",
1372
1437
  "the",
@@ -1409,19 +1474,37 @@ var QUERY_STOPWORDS = /* @__PURE__ */ new Set([
1409
1474
  "these",
1410
1475
  "those"
1411
1476
  ]);
1477
+ var SOLR_OPERATORS = /* @__PURE__ */ new Set(["AND", "OR", "NOT"]);
1412
1478
  var extractQueryTerms = (query) => {
1413
- const matches = query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
1414
- const terms = matches.filter((term) => term.length > 1 && !QUERY_STOPWORDS.has(term));
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
+ }
1485
+ const terms = raw.filter((token) => {
1486
+ if (SOLR_OPERATORS.has(token) && !quoted.has(token)) return false;
1487
+ const term = token.toLowerCase();
1488
+ if (term.length <= 1) return false;
1489
+ if (!QUERY_STOPWORDS.has(term)) return true;
1490
+ return token.length > 1 && token === token.toUpperCase() && token !== term;
1491
+ }).map((token) => token.toLowerCase());
1415
1492
  return Array.from(new Set(terms));
1416
1493
  };
1417
- var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1418
- var textMatchesTerms = (text, terms) => {
1419
- if (!text || terms.length === 0) return false;
1420
- const normalized = text.toLowerCase().replace(/_/g, " ");
1421
- return terms.some((term) => new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(normalized));
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;
1499
+ var countMatchingTerms = (text, terms) => {
1500
+ if (!text || terms.length === 0) return 0;
1501
+ const words = wordsOf(text);
1502
+ return terms.filter((term) => words.some((word) => termMatchesWord(term.normalize("NFC"), word))).length;
1422
1503
  };
1423
1504
  var scoreTextField = (text, terms, weight) => {
1424
- return textMatchesTerms(text, terms) ? weight : 0;
1505
+ const matched = countMatchingTerms(text, terms);
1506
+ if (matched === 0) return 0;
1507
+ return Math.round(weight * matched / terms.length * 10) / 10;
1425
1508
  };
1426
1509
  var readDcatExtra = (dataset, key) => {
1427
1510
  const extras = Array.isArray(dataset.extras) ? dataset.extras : [];
@@ -1434,7 +1517,7 @@ var readDcatExtra = (dataset, key) => {
1434
1517
  const rootValue = dataset[key];
1435
1518
  return typeof rootValue === "string" ? rootValue : "";
1436
1519
  };
1437
- var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS) => {
1520
+ var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS, fullCoverage = false) => {
1438
1521
  const terms = extractQueryTerms(query);
1439
1522
  const titleText = dataset.title || dataset.name || "";
1440
1523
  const notesText = dataset.notes || "";
@@ -1442,6 +1525,7 @@ var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS
1442
1525
  const holderText = readDcatExtra(dataset, "holder_name");
1443
1526
  const publisherText = readDcatExtra(dataset, "publisher_name");
1444
1527
  const breakdown = {
1528
+ coverage: fullCoverage ? weights.coverage : 0,
1445
1529
  title: scoreTextField(titleText, terms, weights.title),
1446
1530
  notes: scoreTextField(notesText, terms, weights.notes),
1447
1531
  tags: 0,
@@ -1457,7 +1541,9 @@ var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS
1457
1541
  });
1458
1542
  breakdown.tags = tagMatch ? weights.tags : 0;
1459
1543
  }
1460
- 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;
1461
1547
  return { total: breakdown.total, breakdown, terms };
1462
1548
  };
1463
1549
  var parseAccessServices = (resource) => {
@@ -1755,9 +1841,10 @@ function normalizePackage(pkg) {
1755
1841
  }
1756
1842
  return pkg;
1757
1843
  }
1758
- function compactSearchResult(result, serverUrl) {
1844
+ function compactSearchResult(result, serverUrl, effectiveQuery) {
1759
1845
  return {
1760
1846
  count: result.count,
1847
+ ...effectiveQuery ? { effective_query: effectiveQuery } : {},
1761
1848
  results: (result.results || []).map((rawPkg) => {
1762
1849
  const pkg = serverUrl && requiresMultilingualNormalization(serverUrl) ? normalizePackage(rawPkg) : rawPkg;
1763
1850
  return {
@@ -2035,7 +2122,11 @@ Typical workflow: ckan_status_show (check locale) \u2192 ckan_package_search (qu
2035
2122
  }
2036
2123
  }
2037
2124
  if (params.response_format === "json" /* JSON */) {
2038
- 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
+ );
2039
2130
  return {
2040
2131
  content: [{ type: "text", text: truncateJson(compact) }]
2041
2132
  };
@@ -2195,7 +2286,9 @@ Args:
2195
2286
  - query (string): Natural language or keyword query (e.g., "mobilit\xE0 urbana", "air quality")
2196
2287
  - limit (number): Number of datasets to return (default: 10)
2197
2288
  - weights (object): Field weights for scoring \u2014 higher weight = more influence on rank
2198
- 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
2199
2292
  Note on holder vs organization: on federated catalogs (e.g. dati.gov.it), \`organization\`
2200
2293
  is the harvesting catalog (e.g. Regione Puglia), while \`holder\` (DCAT-AP_IT dct:rightsHolder)
2201
2294
  is the actual data owner (e.g. Comune di Lecce). Queries like "datasets from a specific Comune"
@@ -2224,7 +2317,8 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2224
2317
  tags: z2.coerce.number().min(0).optional().describe("Weight for tag match (default 3)"),
2225
2318
  organization: z2.coerce.number().min(0).optional().describe("Weight for organization (CKAN catalog / harvester) match (default 1)"),
2226
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)"),
2227
- 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)")
2228
2322
  }).optional().describe("Per-field scoring weights; unspecified fields use defaults"),
2229
2323
  query_parser: z2.enum(["default", "text"]).optional().describe("Override search parser ('text' forces text:(...) on non-fielded queries)"),
2230
2324
  response_format: ResponseFormatSchema
@@ -2242,33 +2336,33 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2242
2336
  ...DEFAULT_RELEVANCE_WEIGHTS,
2243
2337
  ...params.weights ?? {}
2244
2338
  };
2245
- const rows = Math.min(Math.max(params.limit * 5, params.limit), 100);
2339
+ const rows = Math.min(Math.max(params.limit * 5, 50), 100);
2340
+ let parserOverride = params.query_parser;
2341
+ if (!parserOverride && mayNeedTextWrapping(params.query)) {
2342
+ const needsText = await probePortalParser(params.server_url);
2343
+ if (needsText) parserOverride = "text";
2344
+ }
2246
2345
  const { effectiveQuery } = resolveSearchQuery(
2247
2346
  params.server_url,
2248
2347
  params.query,
2249
- params.query_parser
2250
- );
2251
- const searchResult = await makeCkanRequest(
2252
- params.server_url,
2253
- "package_search",
2254
- {
2255
- q: effectiveQuery,
2256
- rows,
2257
- start: 0
2258
- }
2348
+ parserOverride
2259
2349
  );
2260
- const scored = (searchResult.results || []).map((dataset) => {
2261
- const { total, breakdown } = scoreDatasetRelevance(
2262
- params.query,
2263
- dataset,
2264
- weights
2265
- );
2266
- return {
2267
- dataset,
2268
- score: total,
2269
- breakdown
2270
- };
2271
- });
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;
2272
2366
  scored.sort((a, b) => b.score - a.score);
2273
2367
  const top = scored.slice(0, params.limit).map((item) => {
2274
2368
  const dataset = item.dataset;
@@ -2288,6 +2382,7 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2288
2382
  terms: extractQueryTerms(params.query),
2289
2383
  weights,
2290
2384
  total_results: searchResult.count ?? 0,
2385
+ all_terms_results: strictResult ? strictResult.count ?? 0 : null,
2291
2386
  returned: top.length,
2292
2387
  results: top
2293
2388
  };
@@ -2358,6 +2453,8 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
2358
2453
  markdown += `- Organization: ${dataset.breakdown.organization}
2359
2454
  `;
2360
2455
  markdown += `- Holder: ${dataset.breakdown.holder}
2456
+ `;
2457
+ markdown += `- Coverage (every term, per the portal): ${dataset.breakdown.coverage}
2361
2458
  `;
2362
2459
  markdown += `- Publisher: ${dataset.breakdown.publisher}
2363
2460
  `;
@@ -2970,7 +3067,7 @@ Returns:
2970
3067
 
2971
3068
  Examples:
2972
3069
  - { server_url: "https://www.dati.gov.it/opendata", pattern: "toscana" }
2973
- - { server_url: "https://catalog.data.gov", pattern: "health" }
3070
+ - { server_url: "https://open.canada.ca/data", pattern: "health" }
2974
3071
 
2975
3072
  Typical workflow: ckan_organization_search \u2192 ckan_organization_show (get details) \u2192 ckan_package_search with fq="organization:name"`,
2976
3073
  inputSchema: z3.object({
@@ -2987,7 +3084,7 @@ Typical workflow: ckan_organization_search \u2192 ckan_organization_show (get de
2987
3084
  },
2988
3085
  async (params) => {
2989
3086
  try {
2990
- const query = `organization:*${params.pattern.toLowerCase()}*`;
3087
+ const query = `organization:*${stripAccents(params.pattern.toLowerCase())}*`;
2991
3088
  const result = await makeCkanRequest(
2992
3089
  params.server_url,
2993
3090
  "package_search",
@@ -3403,11 +3500,13 @@ Typical workflow: ckan_status_show (verify server is up) \u2192 ckan_package_sea
3403
3500
  structuredContent: cappedStructured(result)
3404
3501
  };
3405
3502
  } catch (error) {
3503
+ const migrated = error instanceof CkanApiError && error.serverUrl && getPortalMigration(error.serverUrl);
3504
+ const detail = formatCkanError(error, "ckan_status_show");
3406
3505
  return {
3407
3506
  content: [{
3408
3507
  type: "text",
3409
- text: `Server appears to be offline or not a valid CKAN instance:
3410
- ${error instanceof Error ? error.message : String(error)}`
3508
+ text: migrated ? detail : `Server appears to be offline or not a valid CKAN instance:
3509
+ ${detail}`
3411
3510
  }],
3412
3511
  isError: true
3413
3512
  };
@@ -3491,7 +3590,13 @@ Typical workflow: ckan_tag_list \u2192 ckan_package_search with fq="tags:tag_nam
3491
3590
  q: params.q,
3492
3591
  rows: 0,
3493
3592
  "facet.field": JSON.stringify(["tags"]),
3494
- "facet.limit": params.limit
3593
+ // The filter runs on whatever the facet returned, so with a tag_query the
3594
+ // facet has to be wider than the caller's limit: on dati.gov.it 53 tags
3595
+ // contain "citta" and none is in the top 100, so asking for 100 answered
3596
+ // "no tags" while they existed. CKAN rejects Solr's own `facet.contains`
3597
+ // ("Invalid search parameters"), so the filtering cannot move server-side;
3598
+ // this window is the compromise, and WIDE_FACET below is the escape hatch.
3599
+ "facet.limit": params.tag_query ? Math.max(params.limit * 20, 500) : params.limit
3495
3600
  };
3496
3601
  if (params.fq) apiParams.fq = params.fq;
3497
3602
  const result = await makeCkanRequest(
@@ -3501,8 +3606,18 @@ Typical workflow: ckan_tag_list \u2192 ckan_package_search with fq="tags:tag_nam
3501
3606
  );
3502
3607
  let tags = normalizeTagFacets(result);
3503
3608
  if (params.tag_query) {
3504
- const needle = params.tag_query.toLowerCase();
3505
- tags = tags.filter((tag) => tag.name.toLowerCase().includes(needle));
3609
+ const needle = stripAccents(params.tag_query.toLowerCase());
3610
+ const matching = (candidates) => candidates.filter((tag) => stripAccents(tag.name.toLowerCase()).includes(needle));
3611
+ tags = matching(tags);
3612
+ if (tags.length < params.limit) {
3613
+ const wide = await makeCkanRequest(
3614
+ params.server_url,
3615
+ "package_search",
3616
+ { ...apiParams, "facet.limit": -1 }
3617
+ );
3618
+ tags = matching(normalizeTagFacets(wide));
3619
+ }
3620
+ tags = tags.sort((a, b) => b.count - a.count).slice(0, params.limit);
3506
3621
  }
3507
3622
  tags = tags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)).slice(0, params.limit);
3508
3623
  if (params.response_format === "json" /* JSON */) {
@@ -5857,7 +5972,7 @@ var registerAllPrompts = (server) => {
5857
5972
  function createServer() {
5858
5973
  return new McpServer({
5859
5974
  name: "ckan-mcp-server",
5860
- version: "0.4.122"
5975
+ version: "0.4.124"
5861
5976
  });
5862
5977
  }
5863
5978
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.122",
3
+ "version": "0.4.124",
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",