@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 +2 -18
- package/LOG.md +129 -0
- package/dist/index.js +95 -50
- 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
|
|
172
|
+
## USA - data.gov
|
|
173
173
|
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1458
|
-
var
|
|
1459
|
-
var
|
|
1460
|
-
var
|
|
1461
|
-
|
|
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
|
|
1468
|
-
return terms.filter((term) =>
|
|
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 =
|
|
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(
|
|
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
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
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://
|
|
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
|
-
${
|
|
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.
|
|
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.
|
|
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",
|