@aborruso/ckan-mcp-server 0.4.123 → 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.
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,136 @@
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
+
5
134
  ### v0.4.123 - relevance scoring and shared parser probe
6
135
 
7
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.
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
  };
@@ -5927,7 +5972,7 @@ var registerAllPrompts = (server) => {
5927
5972
  function createServer() {
5928
5973
  return new McpServer({
5929
5974
  name: "ckan-mcp-server",
5930
- version: "0.4.123"
5975
+ version: "0.4.124"
5931
5976
  });
5932
5977
  }
5933
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.123",
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",