@aborruso/ckan-mcp-server 0.4.121 → 0.4.122

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 +50 -0
  2. package/dist/index.js +107 -52
  3. package/package.json +1 -1
package/LOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  ## 2026-09-05
4
4
 
5
+ ### v0.4.122 - Solr parser fix
6
+
7
+ 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`.
8
+
9
+ ### Solr: wrap only boolean queries, and measure every portal instead of storing a verdict
10
+
11
+ The usage review surfaced an LLM client reformulating the same request against
12
+ `www.dati.gov.it/opendata` for three hours on 29 July. The data was there from the first
13
+ attempt: `bonifica siti contaminati Piemonte` returns 22 datasets on that portal, the
14
+ Piedmont contaminated-sites registry first. Our `text:(...)` rewrite was returning 5.
15
+
16
+ `package_search` hands a colon-free query to Solr's dismax parser with `q.op=AND`,
17
+ `mm='2<-1 5<80%'` and `qf='name^4 title^4 tags^2 groups^2 text'` (`ckan/lib/search/query.py`).
18
+ dismax has no boolean syntax, so `A OR B` collapses into `A AND B`: on dati.gov.it
19
+ `aria OR Milano` returns 59, exactly what `aria AND Milano` returns. A colon takes the query
20
+ off dismax, which is what the wrapper exploits — the wrapped form returns 3421. The same
21
+ switch is why it hurts everything else: it drops the `qf` boosts that rank titles and tags
22
+ first, searches the catch-all `text` field alone, and ANDs every term instead of applying
23
+ `mm`. This is CKAN's own default, not a per-portal defect, which is why the same pattern
24
+ showed up on Milano, Toscana, Sicilia, Ucraina and open.canada.ca.
25
+
26
+ Measured on dati.gov.it: `defibrillatori Comune di Lecce` 678 -> 1, `qualità aria Milano`
27
+ 650 -> 51, `musei roma arte opere catalogo` 9 -> 0 — the second most repeated query of the
28
+ semester, answered with an empty page while nine datasets existed. On 40 real queries from
29
+ telemetry the separation is clean: with a boolean operator the wrapper helped 8 times and
30
+ hurt none; without one it helped none and hurt 10, six of them down to zero.
31
+
32
+ So the wrapper now applies only to queries carrying a boolean operator. The shape that an
33
+ LLM client generates from a user's request — a run of keywords, no operators — is left to
34
+ the portal's own parser, boosts and `mm` included.
35
+
36
+ `probePortalParser` was rewritten and now runs for every portal, configured ones included;
37
+ `force_text_field` is gone from `portals.json` (8 entries) so there is one source of truth.
38
+ The old probe asked `data OR dati`, two words common enough to saturate: on Milano `data`
39
+ alone and `data OR dati` both return 2564, so it read the portal as healthy while
40
+ `aria OR acqua` returned 0 against 54 and 33 for the single terms. It now picks two terms
41
+ from the catalog itself — single-word tag facets between 0.5% and 30%, falling back to
42
+ frequent title words — and compares `A`, `B`, `A OR B`, `text:(A OR B)`. An OR returning
43
+ fewer hits than either operand is not being honoured, and the wrapper is the answer only if
44
+ the wrapped form returns more. `data.stadt-zuerich.ch`, where the `text` field returns 0 for
45
+ every query, is correctly left alone.
46
+
47
+ Cost: a plain query pays nothing, since nothing but a boolean query can be wrapped. The
48
+ probe costs 5 extra `rows=0` calls the first time a boolean query reaches a portal in a
49
+ session, then nothing.
50
+
51
+ Verified e2e against live portals: dati.gov.it 5 -> 22 and 0 -> 9 on the two queries from
52
+ the telemetry, Milano `aria OR acqua` 0 -> 87, Zurich unchanged at 10 and 172. 516 tests
53
+ pass, 18 added.
54
+
5
55
  ### v0.4.121 - clearer 404 on datastore_search_sql
6
56
 
7
57
  Patch release for the fix in #532: a 404 on `datastore_search_sql` now says the portal does not expose the SQL endpoint, instead of sending the caller to `ckan_package_show` for a resource_id that was already valid. Also ships the telemetry pipeline fixes and the DEPLOYMENT.md/CLAUDE.md realignment from earlier today.
package/dist/index.js CHANGED
@@ -33,9 +33,6 @@ var portals_default = {
33
33
  "https://dati.gov.it",
34
34
  "https://www.dati.gov.it"
35
35
  ],
36
- search: {
37
- force_text_field: true
38
- },
39
36
  hvd: {
40
37
  category_field: "hvd_category"
41
38
  },
@@ -52,10 +49,7 @@ var portals_default = {
52
49
  api_url: "https://dati.arpae.it",
53
50
  api_url_aliases: [
54
51
  "http://dati.arpae.it"
55
- ],
56
- search: {
57
- force_text_field: true
58
- }
52
+ ]
59
53
  },
60
54
  {
61
55
  id: "anac-opendata",
@@ -77,9 +71,6 @@ var portals_default = {
77
71
  "http://www.data.gov.uk"
78
72
  ],
79
73
  api_path: "/api/action",
80
- search: {
81
- force_text_field: true
82
- },
83
74
  dataset_view_url: "https://www.data.gov.uk/dataset/{id}/{name}",
84
75
  organization_view_url: "https://www.data.gov.uk/organization/{name}"
85
76
  },
@@ -89,10 +80,7 @@ var portals_default = {
89
80
  api_url: "https://catalog.data.gov",
90
81
  api_url_aliases: [
91
82
  "http://catalog.data.gov"
92
- ],
93
- search: {
94
- force_text_field: true
95
- }
83
+ ]
96
84
  },
97
85
  {
98
86
  id: "open-canada",
@@ -102,10 +90,7 @@ var portals_default = {
102
90
  "http://open.canada.ca/data",
103
91
  "https://open.canada.ca",
104
92
  "http://open.canada.ca"
105
- ],
106
- search: {
107
- force_text_field: true
108
- }
93
+ ]
109
94
  },
110
95
  {
111
96
  id: "data-gov-au",
@@ -147,10 +132,7 @@ var portals_default = {
147
132
  api_url: "https://data.gov.ua",
148
133
  api_url_aliases: [
149
134
  "http://data.gov.ua"
150
- ],
151
- search: {
152
- force_text_field: false
153
- }
135
+ ]
154
136
  },
155
137
  {
156
138
  id: "dati-regione-sicilia",
@@ -158,10 +140,7 @@ var portals_default = {
158
140
  api_url: "https://dati.regione.sicilia.it",
159
141
  api_url_aliases: [
160
142
  "http://dati.regione.sicilia.it"
161
- ],
162
- search: {
163
- force_text_field: false
164
- }
143
+ ]
165
144
  },
166
145
  {
167
146
  id: "bdap-rgs-mef",
@@ -190,10 +169,7 @@ var portals_default = {
190
169
  ],
191
170
  defaults: {
192
171
  dataset_view_url: "{server_url}/dataset/{name}",
193
- organization_view_url: "{server_url}/organization/{name}",
194
- search: {
195
- force_text_field: false
196
- }
172
+ organization_view_url: "{server_url}/organization/{name}"
197
173
  }
198
174
  };
199
175
 
@@ -224,10 +200,6 @@ function getPortalSearchConfig(serverUrl) {
224
200
  force_text_field: portal?.search?.force_text_field ?? defaults.force_text_field ?? false
225
201
  };
226
202
  }
227
- function isPortalSearchExplicitlyConfigured(serverUrl) {
228
- const portal = getPortalConfig(serverUrl);
229
- return portal?.search?.force_text_field !== void 0;
230
- }
231
203
  function normalizePortalUrl(serverUrl) {
232
204
  return normalizeUrl(serverUrl);
233
205
  }
@@ -1222,12 +1194,51 @@ function getOrganizationViewUrl(serverUrl, org) {
1222
1194
  // src/utils/search.ts
1223
1195
  var DEFAULT_SEARCH_QUERY = "*:*";
1224
1196
  var FIELD_QUERY_PATTERN = /\b[a-zA-Z_][\w-]*:/;
1197
+ var EXPLICIT_BOOL_PATTERN = /\b(AND|OR|NOT)\b|[+\-!]/;
1198
+ var BOOL_KEYWORD_PATTERN = /\b(AND|OR|NOT)\b/;
1199
+ var INTRAWORD_PUNCT_PATTERN = /\S[+\-!]\S/;
1225
1200
  var SOLR_SPECIAL_CHARS = /[+\-!(){}[\]^~*?:\\/|&]/g;
1226
1201
  function isFieldedQuery(query) {
1227
1202
  return FIELD_QUERY_PATTERN.test(query);
1228
1203
  }
1229
- function escapeSolrQuery(query) {
1230
- return query.replace(SOLR_SPECIAL_CHARS, "\\$&");
1204
+ function hasExplicitBooleanOperator(query) {
1205
+ const trimmed = query.trim();
1206
+ if (BOOL_KEYWORD_PATTERN.test(trimmed)) return true;
1207
+ return INTRAWORD_PUNCT_PATTERN.test(trimmed);
1208
+ }
1209
+ function mayNeedTextWrapping(query) {
1210
+ const trimmed = query.trim();
1211
+ return trimmed !== DEFAULT_SEARCH_QUERY && !isFieldedQuery(trimmed) && hasExplicitBooleanOperator(trimmed);
1212
+ }
1213
+ function isEscaped(query, offset) {
1214
+ let backslashes = 0;
1215
+ for (let i = offset - 1; i >= 0 && query[i] === "\\"; i--) backslashes++;
1216
+ return backslashes % 2 === 1;
1217
+ }
1218
+ function hasBalancedParens(query) {
1219
+ let depth = 0;
1220
+ for (let i = 0; i < query.length; i++) {
1221
+ if (isEscaped(query, i)) continue;
1222
+ if (query[i] === "(") depth++;
1223
+ else if (query[i] === ")" && --depth < 0) return false;
1224
+ }
1225
+ return depth === 0;
1226
+ }
1227
+ function escapeForTextWrapping(query) {
1228
+ const keepGroups = hasBalancedParens(query);
1229
+ return query.replace(
1230
+ SOLR_SPECIAL_CHARS,
1231
+ (char, offset, whole) => {
1232
+ if (keepGroups && (char === "(" || char === ")") && !isEscaped(whole, offset)) {
1233
+ return char;
1234
+ }
1235
+ const isUnary = char === "+" || char === "-" || char === "!";
1236
+ const previous = offset === 0 ? "" : whole[offset - 1];
1237
+ const atTermStart = offset === 0 || /\s/.test(previous) || keepGroups && previous === "(";
1238
+ const followedByTerm = offset + 1 < whole.length && !/\s/.test(whole[offset + 1]);
1239
+ return isUnary && atTermStart && followedByTerm ? char : `\\${char}`;
1240
+ }
1241
+ );
1231
1242
  }
1232
1243
  function convertDateMathForUnsupportedFields(query) {
1233
1244
  const now = /* @__PURE__ */ new Date();
@@ -1254,7 +1265,6 @@ function convertDateMathForUnsupportedFields(query) {
1254
1265
  return `${field}:[${startIso} TO ${nowIso}]`;
1255
1266
  });
1256
1267
  }
1257
- var EXPLICIT_BOOL_PATTERN = /\b(AND|OR|NOT)\b|[+\-!]/;
1258
1268
  function isPlainMultiTermQuery(query) {
1259
1269
  const trimmed = query.trim();
1260
1270
  if (trimmed === "*:*" || trimmed === "") return false;
@@ -1283,27 +1293,69 @@ function resolveSearchQuery(serverUrl, query, parserOverride) {
1283
1293
  forceTextField = false;
1284
1294
  } else if (portalForce) {
1285
1295
  const trimmedQuery = query.trim();
1286
- forceTextField = trimmedQuery !== DEFAULT_SEARCH_QUERY && !isFieldedQuery(trimmedQuery);
1296
+ forceTextField = trimmedQuery !== DEFAULT_SEARCH_QUERY && !isFieldedQuery(trimmedQuery) && hasExplicitBooleanOperator(trimmedQuery);
1287
1297
  }
1288
- let effectiveQuery = forceTextField ? `text:(${escapeSolrQuery(query)})` : query;
1298
+ let effectiveQuery = forceTextField ? `text:(${escapeForTextWrapping(query)})` : query;
1289
1299
  effectiveQuery = convertDateMathForUnsupportedFields(effectiveQuery);
1290
1300
  return { effectiveQuery, forcedTextField: forceTextField };
1291
1301
  }
1292
1302
 
1293
1303
  // src/tools/package.ts
1294
1304
  var _portalParserCache = /* @__PURE__ */ new Map();
1305
+ var LITERAL_TERM = /^[\p{L}\p{N}_]+$/u;
1306
+ async function pickProbeTerms(serverUrl) {
1307
+ const facetRes = await makeCkanRequest(serverUrl, "package_search", {
1308
+ q: "*:*",
1309
+ rows: 0,
1310
+ "facet.field": '["tags"]',
1311
+ "facet.limit": 100
1312
+ }).catch(() => null);
1313
+ const total = facetRes?.count ?? 0;
1314
+ if (!total) return null;
1315
+ const items = facetRes?.search_facets?.tags?.items ?? [];
1316
+ const usable = items.filter((i) => typeof i.name === "string" && LITERAL_TERM.test(i.name)).filter((i) => (i.count ?? 0) >= total * 5e-3 && (i.count ?? 0) <= total * 0.3).sort((a, b) => (b.count ?? 0) - (a.count ?? 0));
1317
+ if (usable.length >= 2) return [usable[0].name, usable[1].name];
1318
+ const sampleRes = await makeCkanRequest(serverUrl, "package_search", {
1319
+ q: "*:*",
1320
+ rows: 25
1321
+ }).catch(() => null);
1322
+ const titles = (sampleRes?.results ?? []).map((r) => r?.title ?? "");
1323
+ const words = titles.map((t) => new Set(t.toLowerCase().split(/[^\p{L}\p{N}_]+/u).filter((w) => w.length > 4)));
1324
+ const freq = /* @__PURE__ */ new Map();
1325
+ for (const set of words) for (const w of set) freq.set(w, (freq.get(w) ?? 0) + 1);
1326
+ const ranked = [...freq.entries()].filter(([w]) => LITERAL_TERM.test(w)).sort((a, b) => b[1] - a[1]);
1327
+ const first = ranked[0]?.[0];
1328
+ if (!first) return null;
1329
+ const independent = ranked.slice(1).find(([w]) => words.some((set) => set.has(w) && !set.has(first)));
1330
+ const second = independent?.[0] ?? ranked[1]?.[0];
1331
+ return second ? [first, second] : null;
1332
+ }
1295
1333
  async function probePortalParser(serverUrl) {
1296
1334
  const key = serverUrl.replace(/\/$/, "").toLowerCase();
1297
1335
  if (_portalParserCache.has(key)) return _portalParserCache.get(key);
1298
- const probe = "data OR dati";
1299
- const [defaultRes, textRes] = await Promise.allSettled([
1300
- makeCkanRequest(serverUrl, "package_search", { q: probe, rows: 0 }),
1301
- makeCkanRequest(serverUrl, "package_search", { q: `text:(${probe})`, rows: 0 })
1302
- ]);
1303
- const defaultCount = defaultRes.status === "fulfilled" ? defaultRes.value.count ?? 0 : 0;
1304
- const textCount = textRes.status === "fulfilled" ? textRes.value.count ?? 0 : 0;
1305
- const needsText = textCount > 0 && (defaultCount === 0 || textCount > defaultCount * 2);
1306
- _portalParserCache.set(key, needsText);
1336
+ const terms = await pickProbeTerms(serverUrl).catch(() => null);
1337
+ if (!terms) return false;
1338
+ let needsText = false;
1339
+ let measured = false;
1340
+ {
1341
+ const [a, b] = terms;
1342
+ const count = async (q) => {
1343
+ const res = await makeCkanRequest(serverUrl, "package_search", { q, rows: 0 }).catch(() => null);
1344
+ return typeof res?.count === "number" ? res.count : null;
1345
+ };
1346
+ const [ca, cb, cOr, cText] = await Promise.all([
1347
+ count(a),
1348
+ count(b),
1349
+ count(`${a} OR ${b}`),
1350
+ count(`text:(${a} OR ${b})`)
1351
+ ]);
1352
+ if (ca !== null && cb !== null && cOr !== null && cText !== null) {
1353
+ const booleanIgnored = cOr < Math.max(ca, cb);
1354
+ needsText = booleanIgnored && cText > cOr;
1355
+ measured = true;
1356
+ }
1357
+ }
1358
+ if (measured) _portalParserCache.set(key, needsText);
1307
1359
  return needsText;
1308
1360
  }
1309
1361
  var DEFAULT_RELEVANCE_WEIGHTS = {
@@ -1934,7 +1986,7 @@ Typical workflow: ckan_status_show (check locale) \u2192 ckan_package_search (qu
1934
1986
  if (!effectiveSort) effectiveSort = "issued desc, metadata_created desc";
1935
1987
  }
1936
1988
  let parserOverride = params.query_parser;
1937
- if (!parserOverride && !isPortalSearchExplicitlyConfigured(params.server_url)) {
1989
+ if (!parserOverride && mayNeedTextWrapping(query)) {
1938
1990
  const needsText = await probePortalParser(params.server_url);
1939
1991
  if (needsText) parserOverride = "text";
1940
1992
  }
@@ -1967,7 +2019,10 @@ Typical workflow: ckan_status_show (check locale) \u2192 ckan_package_search (qu
1967
2019
  const { effectiveQuery: strippedEffective } = resolveSearchQuery(
1968
2020
  params.server_url,
1969
2021
  strippedQuery,
1970
- params.query_parser
2022
+ // parserOverride, not params.query_parser: the probe's verdict must
2023
+ // survive the retry, or an accented boolean query is re-sent to the
2024
+ // parser that ignores booleans.
2025
+ parserOverride
1971
2026
  );
1972
2027
  const fallbackResult = await makeCkanRequest(
1973
2028
  params.server_url,
@@ -2092,7 +2147,7 @@ Note: showing top ${sorted.length} only. Use \`response_format: json\` for full
2092
2147
  `;
2093
2148
  if (isPlainMultiTermQuery(params.q)) {
2094
2149
  markdown += `
2095
- > **Tip**: Multi-term queries use AND by default (all terms must match). Try OR to broaden the search:
2150
+ > **Tip**: With several terms the portal requires most of them to match. Spelling out OR broadens the search:
2096
2151
  `;
2097
2152
  markdown += `> \`q: "${buildOrQuery(params.q)}"\`
2098
2153
  `;
@@ -5802,7 +5857,7 @@ var registerAllPrompts = (server) => {
5802
5857
  function createServer() {
5803
5858
  return new McpServer({
5804
5859
  name: "ckan-mcp-server",
5805
- version: "0.4.121"
5860
+ version: "0.4.122"
5806
5861
  });
5807
5862
  }
5808
5863
  function registerAll(server) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.121",
3
+ "version": "0.4.122",
4
4
  "mcpName": "io.github.aborruso/ckan-mcp-server",
5
5
  "description": "MCP server for interacting with CKAN open data portals",
6
6
  "repository": {