@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.
- package/EXAMPLES.md +2 -18
- package/LOG.md +186 -0
- package/dist/index.js +168 -53
- 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,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
|
-
|
|
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
|
|
1414
|
-
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
|
+
}
|
|
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
|
|
1418
|
-
var
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
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://
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|