@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.
Files changed (3) hide show
  1. package/LOG.md +57 -0
  2. package/dist/index.js +82 -12
  3. 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 matches = query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
1414
- const terms = matches.filter((term) => term.length > 1 && !QUERY_STOPWORDS.has(term));
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.toLowerCase().replace(/_/g, " ");
1421
- return terms.some((term) => new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(normalized));
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
- return textMatchesTerms(text, terms) ? weight : 0;
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, params.limit), 100);
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
- params.query_parser
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
- "facet.limit": params.limit
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
- tags = tags.filter((tag) => tag.name.toLowerCase().includes(needle));
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.122"
5930
+ version: "0.4.123"
5861
5931
  });
5862
5932
  }
5863
5933
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.122",
3
+ "version": "0.4.123",
4
4
  "mcpName": "io.github.aborruso/ckan-mcp-server",
5
5
  "description": "MCP server for interacting with CKAN open data portals",
6
6
  "repository": {