@aborruso/ckan-mcp-server 0.4.122 → 0.4.123
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/LOG.md +57 -0
- package/dist/index.js +82 -12
- package/package.json +1 -1
package/LOG.md
CHANGED
|
@@ -2,6 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
## 2026-09-05
|
|
4
4
|
|
|
5
|
+
### v0.4.123 - relevance scoring and shared parser probe
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
### Relevance scoring: three defects that only became visible once search worked
|
|
10
|
+
|
|
11
|
+
Testing v0.4.122 through an MCP client, not curl, showed `ckan_find_relevant_datasets`
|
|
12
|
+
answering its own documented example badly. `defibrillatori Comune di Lecce` on
|
|
13
|
+
dati.gov.it used to return the catalog's only Lecce dataset — because the search
|
|
14
|
+
returned exactly one result. With recall restored it returns 679, and the top three
|
|
15
|
+
became Trento, Martina Franca and Desio while the Lecce dataset fell out of the window.
|
|
16
|
+
|
|
17
|
+
The wrapping fix did not cause this; it removed the cover. Three defects, all older:
|
|
18
|
+
|
|
19
|
+
- `scoreTextField` awarded the **whole** field weight when **any** query term matched.
|
|
20
|
+
"Comune di Martina Franca" and "Comune di Lecce" both scored a full holder match, so
|
|
21
|
+
the right dataset could not outrank the wrong ones. It now scores the share of terms
|
|
22
|
+
the field carries.
|
|
23
|
+
- the stopword list was English-only, so `di` counted as a term: "Provincia Autonoma
|
|
24
|
+
**di** Trento" earned a full holder match on a query asking for Lecce. Italian
|
|
25
|
+
stopwords added.
|
|
26
|
+
- `\b` is ASCII-only in JavaScript, so a term ending in an accented letter never found
|
|
27
|
+
its word boundary: `mobilità` scored 0 against "mobilità urbana", `qualità` against
|
|
28
|
+
"qualità dell'aria". On a catalog that is mostly not in English this silently sank
|
|
29
|
+
every accented query. Replaced with Unicode lookarounds.
|
|
30
|
+
|
|
31
|
+
Looking for more of the same family turned up three more, all cases of a local filter
|
|
32
|
+
running over a truncated or wrongly-normalised set:
|
|
33
|
+
|
|
34
|
+
- `ckan_find_relevant_datasets` never called the parser probe. `portals.json` used to
|
|
35
|
+
cover it; removing `force_text_field` left it sending boolean queries to the parser that
|
|
36
|
+
ignores them. On dati.comune.milano.it `aria OR acqua` returned 0 there against 87 from
|
|
37
|
+
`ckan_package_search`. Same probe now applies to both.
|
|
38
|
+
- `ckan_organization_search` builds a Solr wildcard, which bypasses the analysis chain, so
|
|
39
|
+
the pattern must be pre-normalised the way CKAN builds a name slug. It lowercased but did
|
|
40
|
+
not fold accents: `città` returned 0 while `citta` matched 135 datasets.
|
|
41
|
+
- `ckan_tag_list` applied `tag_query` after faceting, with `facet.limit` set to the
|
|
42
|
+
caller's `limit`. On dati.gov.it 53 tags contain "citta" and none is in the top 100, so
|
|
43
|
+
the filter answered "no tags" while they existed. The facet is now widened when a filter
|
|
44
|
+
is given.
|
|
45
|
+
|
|
46
|
+
`openspec/specs/ckan-search/spec.md` described the parser as a property of
|
|
47
|
+
`ckan_package_search` alone, which is what let the second caller go unnoticed — and after
|
|
48
|
+
yesterday it was also wrong, still describing the per-portal default that was removed.
|
|
49
|
+
Rewritten as a property of the query-building path, naming every tool that shares it.
|
|
50
|
+
|
|
51
|
+
Also raised the candidate window to at least 50: the local ranking only sees what Solr
|
|
52
|
+
returns first, and `limit: 3` shrank it to 15 — enough when a search returned a handful
|
|
53
|
+
of results, not enough now.
|
|
54
|
+
|
|
55
|
+
`defibrillatori Comune di Lecce` puts the Lecce dataset first again. 532 tests, 5 added.
|
|
56
|
+
|
|
57
|
+
How it was missed: yesterday's verification checked result **counts** through
|
|
58
|
+
`ckan_package_search`, never the ranked output of `ckan_find_relevant_datasets` — the
|
|
59
|
+
third most used tool in the telemetry, and the second caller of `resolveSearchQuery`.
|
|
60
|
+
Counting results proves recall, not usefulness.
|
|
61
|
+
|
|
5
62
|
### v0.4.122 - Solr parser fix
|
|
6
63
|
|
|
7
64
|
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
|
@@ -1367,6 +1367,41 @@ var DEFAULT_RELEVANCE_WEIGHTS = {
|
|
|
1367
1367
|
publisher: 2
|
|
1368
1368
|
};
|
|
1369
1369
|
var QUERY_STOPWORDS = /* @__PURE__ */ new Set([
|
|
1370
|
+
// Italian: without these, `defibrillatori Comune di Lecce` scored a full holder
|
|
1371
|
+
// match against "Provincia Autonoma di Trento" on the strength of "di" alone.
|
|
1372
|
+
"di",
|
|
1373
|
+
"del",
|
|
1374
|
+
"dello",
|
|
1375
|
+
"della",
|
|
1376
|
+
"dei",
|
|
1377
|
+
"degli",
|
|
1378
|
+
"delle",
|
|
1379
|
+
"il",
|
|
1380
|
+
"lo",
|
|
1381
|
+
"la",
|
|
1382
|
+
"i",
|
|
1383
|
+
"gli",
|
|
1384
|
+
"le",
|
|
1385
|
+
"un",
|
|
1386
|
+
"uno",
|
|
1387
|
+
"una",
|
|
1388
|
+
"e",
|
|
1389
|
+
"ed",
|
|
1390
|
+
"per",
|
|
1391
|
+
"con",
|
|
1392
|
+
"su",
|
|
1393
|
+
"da",
|
|
1394
|
+
"dal",
|
|
1395
|
+
"dalla",
|
|
1396
|
+
"nel",
|
|
1397
|
+
"nella",
|
|
1398
|
+
"al",
|
|
1399
|
+
"alla",
|
|
1400
|
+
"che",
|
|
1401
|
+
"non",
|
|
1402
|
+
"come",
|
|
1403
|
+
"dove",
|
|
1404
|
+
"sono",
|
|
1370
1405
|
"a",
|
|
1371
1406
|
"an",
|
|
1372
1407
|
"the",
|
|
@@ -1410,18 +1445,32 @@ var QUERY_STOPWORDS = /* @__PURE__ */ new Set([
|
|
|
1410
1445
|
"those"
|
|
1411
1446
|
]);
|
|
1412
1447
|
var extractQueryTerms = (query) => {
|
|
1413
|
-
const
|
|
1414
|
-
const terms =
|
|
1448
|
+
const raw = query.normalize("NFC").match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
1449
|
+
const terms = raw.filter((token) => {
|
|
1450
|
+
const term = token.toLowerCase();
|
|
1451
|
+
if (term.length <= 1) return false;
|
|
1452
|
+
if (!QUERY_STOPWORDS.has(term)) return true;
|
|
1453
|
+
return token.length > 1 && token === token.toUpperCase() && token !== term;
|
|
1454
|
+
}).map((token) => token.toLowerCase());
|
|
1415
1455
|
return Array.from(new Set(terms));
|
|
1416
1456
|
};
|
|
1417
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, " ");
|
|
1418
1460
|
var textMatchesTerms = (text, terms) => {
|
|
1419
1461
|
if (!text || terms.length === 0) return false;
|
|
1420
|
-
const normalized = text
|
|
1421
|
-
return terms.some((term) =>
|
|
1462
|
+
const normalized = normalizeForMatch(text);
|
|
1463
|
+
return terms.some((term) => termPattern(term).test(normalized));
|
|
1464
|
+
};
|
|
1465
|
+
var countMatchingTerms = (text, terms) => {
|
|
1466
|
+
if (!text || terms.length === 0) return 0;
|
|
1467
|
+
const normalized = normalizeForMatch(text);
|
|
1468
|
+
return terms.filter((term) => termPattern(term).test(normalized)).length;
|
|
1422
1469
|
};
|
|
1423
1470
|
var scoreTextField = (text, terms, weight) => {
|
|
1424
|
-
|
|
1471
|
+
const matched = countMatchingTerms(text, terms);
|
|
1472
|
+
if (matched === 0) return 0;
|
|
1473
|
+
return Math.round(weight * matched / terms.length * 10) / 10;
|
|
1425
1474
|
};
|
|
1426
1475
|
var readDcatExtra = (dataset, key) => {
|
|
1427
1476
|
const extras = Array.isArray(dataset.extras) ? dataset.extras : [];
|
|
@@ -2242,11 +2291,16 @@ Typical workflow: ckan_find_relevant_datasets \u2192 ckan_package_show (inspect
|
|
|
2242
2291
|
...DEFAULT_RELEVANCE_WEIGHTS,
|
|
2243
2292
|
...params.weights ?? {}
|
|
2244
2293
|
};
|
|
2245
|
-
const rows = Math.min(Math.max(params.limit * 5,
|
|
2294
|
+
const rows = Math.min(Math.max(params.limit * 5, 50), 100);
|
|
2295
|
+
let parserOverride = params.query_parser;
|
|
2296
|
+
if (!parserOverride && mayNeedTextWrapping(params.query)) {
|
|
2297
|
+
const needsText = await probePortalParser(params.server_url);
|
|
2298
|
+
if (needsText) parserOverride = "text";
|
|
2299
|
+
}
|
|
2246
2300
|
const { effectiveQuery } = resolveSearchQuery(
|
|
2247
2301
|
params.server_url,
|
|
2248
2302
|
params.query,
|
|
2249
|
-
|
|
2303
|
+
parserOverride
|
|
2250
2304
|
);
|
|
2251
2305
|
const searchResult = await makeCkanRequest(
|
|
2252
2306
|
params.server_url,
|
|
@@ -2987,7 +3041,7 @@ Typical workflow: ckan_organization_search \u2192 ckan_organization_show (get de
|
|
|
2987
3041
|
},
|
|
2988
3042
|
async (params) => {
|
|
2989
3043
|
try {
|
|
2990
|
-
const query = `organization:*${params.pattern.toLowerCase()}*`;
|
|
3044
|
+
const query = `organization:*${stripAccents(params.pattern.toLowerCase())}*`;
|
|
2991
3045
|
const result = await makeCkanRequest(
|
|
2992
3046
|
params.server_url,
|
|
2993
3047
|
"package_search",
|
|
@@ -3491,7 +3545,13 @@ Typical workflow: ckan_tag_list \u2192 ckan_package_search with fq="tags:tag_nam
|
|
|
3491
3545
|
q: params.q,
|
|
3492
3546
|
rows: 0,
|
|
3493
3547
|
"facet.field": JSON.stringify(["tags"]),
|
|
3494
|
-
|
|
3548
|
+
// The filter runs on whatever the facet returned, so with a tag_query the
|
|
3549
|
+
// facet has to be wider than the caller's limit: on dati.gov.it 53 tags
|
|
3550
|
+
// contain "citta" and none is in the top 100, so asking for 100 answered
|
|
3551
|
+
// "no tags" while they existed. CKAN rejects Solr's own `facet.contains`
|
|
3552
|
+
// ("Invalid search parameters"), so the filtering cannot move server-side;
|
|
3553
|
+
// this window is the compromise, and WIDE_FACET below is the escape hatch.
|
|
3554
|
+
"facet.limit": params.tag_query ? Math.max(params.limit * 20, 500) : params.limit
|
|
3495
3555
|
};
|
|
3496
3556
|
if (params.fq) apiParams.fq = params.fq;
|
|
3497
3557
|
const result = await makeCkanRequest(
|
|
@@ -3501,8 +3561,18 @@ Typical workflow: ckan_tag_list \u2192 ckan_package_search with fq="tags:tag_nam
|
|
|
3501
3561
|
);
|
|
3502
3562
|
let tags = normalizeTagFacets(result);
|
|
3503
3563
|
if (params.tag_query) {
|
|
3504
|
-
const needle = params.tag_query.toLowerCase();
|
|
3505
|
-
|
|
3564
|
+
const needle = stripAccents(params.tag_query.toLowerCase());
|
|
3565
|
+
const matching = (candidates) => candidates.filter((tag) => stripAccents(tag.name.toLowerCase()).includes(needle));
|
|
3566
|
+
tags = matching(tags);
|
|
3567
|
+
if (tags.length < params.limit) {
|
|
3568
|
+
const wide = await makeCkanRequest(
|
|
3569
|
+
params.server_url,
|
|
3570
|
+
"package_search",
|
|
3571
|
+
{ ...apiParams, "facet.limit": -1 }
|
|
3572
|
+
);
|
|
3573
|
+
tags = matching(normalizeTagFacets(wide));
|
|
3574
|
+
}
|
|
3575
|
+
tags = tags.sort((a, b) => b.count - a.count).slice(0, params.limit);
|
|
3506
3576
|
}
|
|
3507
3577
|
tags = tags.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)).slice(0, params.limit);
|
|
3508
3578
|
if (params.response_format === "json" /* JSON */) {
|
|
@@ -5857,7 +5927,7 @@ var registerAllPrompts = (server) => {
|
|
|
5857
5927
|
function createServer() {
|
|
5858
5928
|
return new McpServer({
|
|
5859
5929
|
name: "ckan-mcp-server",
|
|
5860
|
-
version: "0.4.
|
|
5930
|
+
version: "0.4.123"
|
|
5861
5931
|
});
|
|
5862
5932
|
}
|
|
5863
5933
|
function registerAll(server) {
|