@aborruso/ckan-mcp-server 0.4.120 → 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.
- package/LOG.md +97 -0
- package/dist/index.js +110 -53
- package/package.json +1 -1
package/LOG.md
CHANGED
|
@@ -2,6 +2,103 @@
|
|
|
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
|
+
|
|
55
|
+
### v0.4.121 - clearer 404 on datastore_search_sql
|
|
56
|
+
|
|
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.
|
|
58
|
+
|
|
59
|
+
### DEPLOYMENT.md and CLAUDE.md realigned with the actual deployment
|
|
60
|
+
|
|
61
|
+
Six things the deployment guide got wrong, all verified against the live system:
|
|
62
|
+
|
|
63
|
+
- tool count stuck at 7; `/health` reports 20 tools, 7 resources, 6 prompts on v0.4.120
|
|
64
|
+
- bundle "~400KB" in two places, while the doc's own sample output said 541 KiB; rebuilt: 533 KB
|
|
65
|
+
- the free tier presented as the configuration in use. Workers Observability keeps 7 days of telemetry on this account (measured: events at 7 days back, none at 8), which is the paid retention — the free-tier request and CPU limits are not the ones this deployment operates under
|
|
66
|
+
- `demo.ckan.org` in three curl examples, which CLAUDE.md forbids for tests
|
|
67
|
+
- the release workflow told you to commit and push straight to `main`, in both files. Now branch, PR, squash-merge, then tag on the merged commit
|
|
68
|
+
- a stale `Co-Authored-By` line in the sample commit
|
|
69
|
+
|
|
70
|
+
Also corrected the troubleshooting entry calling CPU-limit errors "rare": 22 in the first four days of September against 11 in all of August. Left flagged as unexplained.
|
|
71
|
+
|
|
72
|
+
Left alone on purpose: archived OpenSpec proposals, historical LOG entries, and `demo.ckan.org` where it appears as an example for end users (README, EXAMPLES, SKILL) rather than as a test target.
|
|
73
|
+
|
|
74
|
+
### 404 on datastore_search_sql: hint sent callers round a loop
|
|
75
|
+
|
|
76
|
+
A 404 on `datastore_search_sql` fell into the generic `datastore_search` branch of
|
|
77
|
+
`formatCkanError`, which answers "get a valid resource_id first: call `ckan_package_show`".
|
|
78
|
+
On a portal that does not expose SQL the resource_id is fine and `ckan_package_show`
|
|
79
|
+
hands back the same one, so a client following the hint retries into the same 404.
|
|
80
|
+
|
|
81
|
+
SQL access is optional in CKAN and widely disabled: `datastore_search_sql?sql=SELECT 1`
|
|
82
|
+
returns 404 on Toronto's portal and 200 on dati.comune.messina.it, while
|
|
83
|
+
`datastore_search` answers 200 on both. A bad table name comes back as a 400, not a 404,
|
|
84
|
+
so a 404 on that action means the endpoint itself is missing. The hint now says so and
|
|
85
|
+
points at `ckan_datastore_search`.
|
|
86
|
+
|
|
87
|
+
Verified e2e: Toronto returns the new hint; Messina counts 202.906 rows in a single SQL
|
|
88
|
+
call. 498 tests pass, one added.
|
|
89
|
+
|
|
90
|
+
### Telemetry pipeline: cross-worker contamination, missing outcome, unattributable errors
|
|
91
|
+
|
|
92
|
+
A usage review of `worker_events_flat.jsonl` surfaced three defects in the measurement itself, all confirmed against the live Observability API:
|
|
93
|
+
|
|
94
|
+
- The query filtered only on `$metadata.type = cf-worker`, with no `service` filter, so events from `opensdmx-mcp` (another Worker on the same account) were archived here: 7 out of 31 events in a 48h probe, 589 `sdmx_*` records in the May-July history. Added `$metadata.service = ckan-mcp-server` (env override `CF_SCRIPT_NAME`); the probe then returned 24/24 ckan events. The existing records cannot be cleaned: retention is 3 days.
|
|
95
|
+
- `$workers.outcome` disappeared from the API on 2026-07-12 (the object now carries `spanId`/`traceId` instead). Not a regression of ours: the archiver's only refactor is 7e78b32, March. The replacement was already there and unused: `src/worker.ts` logs its own `status` per call, plus `cache_hit`, `duration_ms` and `limit`, and the archiver was throwing all four away. `resolve_outcome()` now reads `$workers.outcome`, then `source.status`, then `$metadata.level`, and writes `unknown` rather than assuming `ok`. On a 47-event live sample it resolved every record (40 ok, 7 error, no unknown), CPU-limit crashes included. July-September stays null and cannot be backfilled: retention is 3 days.
|
|
96
|
+
- `worker_daily_stats.sh` had been writing `ok:0, errors:0` on every row since that date. It now derives the error flag from `error IS NOT NULL OR outcome IN (...)`, which stays valid across the schema change. March figures are unchanged; August and September are populated again (2026-09-03: 240 calls, 220 ok, 20 errors).
|
|
97
|
+
|
|
98
|
+
Flat schema gains `script_version`, `request_id`, `trigger`, `cache_hit`, `duration_ms`, `limit`. Without the first, a CPU-limit event cannot be tied to a release, and those events carry no `tool` or `server` either; they now do carry the version, so September's 22 CPU errors in 4 days (against 11 in the whole of August) become attributable from here on, not retroactively. `cache_hit` and `limit` answer the two questions this review could only guess at: cache fragmentation from URL spelling, and page size on the long datastore loops. Older records are padded to the same field set on rewrite.
|
|
99
|
+
|
|
100
|
+
Usage, excluding March (evaluation traffic: 2.953 calls, 242 portals) and the sdmx events: ~470-1.450 calls a month, median 13,5 a day, very spiky. `ckan_datastore_search` leads (1.142 since June), and on 2026-08-24 a single client generated 392 calls with 6 distinct queries paging Toronto's datastore. Callers spell the same portal in three or four ways (`dati.gov.it/opendata`, `www.` variant, trailing slash; `open.canada.ca` in three forms), which fragments the cache key.
|
|
101
|
+
|
|
5
102
|
### v0.4.120 - SSRF hardening release + dependency audit
|
|
6
103
|
|
|
7
104
|
Releases PR #531 (env-proxy refusal, multicast/reserved/site-local ranges, see 2026-09-04 entry). Also:
|
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
|
}
|
|
@@ -496,7 +468,9 @@ function formatCkanError(error, _toolName) {
|
|
|
496
468
|
const { status, action, message } = error;
|
|
497
469
|
let hint = "";
|
|
498
470
|
if (status === 404) {
|
|
499
|
-
if (action
|
|
471
|
+
if (action === "datastore_search_sql") {
|
|
472
|
+
hint = "\u2192 This portal does not expose the SQL endpoint (optional in CKAN, often disabled). Use `ckan_datastore_search` with `filters`/`sort` instead; the resource_id is probably fine.";
|
|
473
|
+
} else if (action.startsWith("datastore_search")) {
|
|
500
474
|
hint = "\u2192 Get a valid resource_id first: call `ckan_package_show` on a dataset, then pick a resource where `datastore_active` is true.";
|
|
501
475
|
} else if (action === "package_show") {
|
|
502
476
|
hint = "\u2192 Use `ckan_package_search` to find a valid dataset name or ID.";
|
|
@@ -1220,12 +1194,51 @@ function getOrganizationViewUrl(serverUrl, org) {
|
|
|
1220
1194
|
// src/utils/search.ts
|
|
1221
1195
|
var DEFAULT_SEARCH_QUERY = "*:*";
|
|
1222
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/;
|
|
1223
1200
|
var SOLR_SPECIAL_CHARS = /[+\-!(){}[\]^~*?:\\/|&]/g;
|
|
1224
1201
|
function isFieldedQuery(query) {
|
|
1225
1202
|
return FIELD_QUERY_PATTERN.test(query);
|
|
1226
1203
|
}
|
|
1227
|
-
function
|
|
1228
|
-
|
|
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
|
+
);
|
|
1229
1242
|
}
|
|
1230
1243
|
function convertDateMathForUnsupportedFields(query) {
|
|
1231
1244
|
const now = /* @__PURE__ */ new Date();
|
|
@@ -1252,7 +1265,6 @@ function convertDateMathForUnsupportedFields(query) {
|
|
|
1252
1265
|
return `${field}:[${startIso} TO ${nowIso}]`;
|
|
1253
1266
|
});
|
|
1254
1267
|
}
|
|
1255
|
-
var EXPLICIT_BOOL_PATTERN = /\b(AND|OR|NOT)\b|[+\-!]/;
|
|
1256
1268
|
function isPlainMultiTermQuery(query) {
|
|
1257
1269
|
const trimmed = query.trim();
|
|
1258
1270
|
if (trimmed === "*:*" || trimmed === "") return false;
|
|
@@ -1281,27 +1293,69 @@ function resolveSearchQuery(serverUrl, query, parserOverride) {
|
|
|
1281
1293
|
forceTextField = false;
|
|
1282
1294
|
} else if (portalForce) {
|
|
1283
1295
|
const trimmedQuery = query.trim();
|
|
1284
|
-
forceTextField = trimmedQuery !== DEFAULT_SEARCH_QUERY && !isFieldedQuery(trimmedQuery);
|
|
1296
|
+
forceTextField = trimmedQuery !== DEFAULT_SEARCH_QUERY && !isFieldedQuery(trimmedQuery) && hasExplicitBooleanOperator(trimmedQuery);
|
|
1285
1297
|
}
|
|
1286
|
-
let effectiveQuery = forceTextField ? `text:(${
|
|
1298
|
+
let effectiveQuery = forceTextField ? `text:(${escapeForTextWrapping(query)})` : query;
|
|
1287
1299
|
effectiveQuery = convertDateMathForUnsupportedFields(effectiveQuery);
|
|
1288
1300
|
return { effectiveQuery, forcedTextField: forceTextField };
|
|
1289
1301
|
}
|
|
1290
1302
|
|
|
1291
1303
|
// src/tools/package.ts
|
|
1292
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
|
+
}
|
|
1293
1333
|
async function probePortalParser(serverUrl) {
|
|
1294
1334
|
const key = serverUrl.replace(/\/$/, "").toLowerCase();
|
|
1295
1335
|
if (_portalParserCache.has(key)) return _portalParserCache.get(key);
|
|
1296
|
-
const
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
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);
|
|
1305
1359
|
return needsText;
|
|
1306
1360
|
}
|
|
1307
1361
|
var DEFAULT_RELEVANCE_WEIGHTS = {
|
|
@@ -1932,7 +1986,7 @@ Typical workflow: ckan_status_show (check locale) \u2192 ckan_package_search (qu
|
|
|
1932
1986
|
if (!effectiveSort) effectiveSort = "issued desc, metadata_created desc";
|
|
1933
1987
|
}
|
|
1934
1988
|
let parserOverride = params.query_parser;
|
|
1935
|
-
if (!parserOverride &&
|
|
1989
|
+
if (!parserOverride && mayNeedTextWrapping(query)) {
|
|
1936
1990
|
const needsText = await probePortalParser(params.server_url);
|
|
1937
1991
|
if (needsText) parserOverride = "text";
|
|
1938
1992
|
}
|
|
@@ -1965,7 +2019,10 @@ Typical workflow: ckan_status_show (check locale) \u2192 ckan_package_search (qu
|
|
|
1965
2019
|
const { effectiveQuery: strippedEffective } = resolveSearchQuery(
|
|
1966
2020
|
params.server_url,
|
|
1967
2021
|
strippedQuery,
|
|
1968
|
-
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
|
|
1969
2026
|
);
|
|
1970
2027
|
const fallbackResult = await makeCkanRequest(
|
|
1971
2028
|
params.server_url,
|
|
@@ -2090,7 +2147,7 @@ Note: showing top ${sorted.length} only. Use \`response_format: json\` for full
|
|
|
2090
2147
|
`;
|
|
2091
2148
|
if (isPlainMultiTermQuery(params.q)) {
|
|
2092
2149
|
markdown += `
|
|
2093
|
-
> **Tip**:
|
|
2150
|
+
> **Tip**: With several terms the portal requires most of them to match. Spelling out OR broadens the search:
|
|
2094
2151
|
`;
|
|
2095
2152
|
markdown += `> \`q: "${buildOrQuery(params.q)}"\`
|
|
2096
2153
|
`;
|
|
@@ -5800,7 +5857,7 @@ var registerAllPrompts = (server) => {
|
|
|
5800
5857
|
function createServer() {
|
|
5801
5858
|
return new McpServer({
|
|
5802
5859
|
name: "ckan-mcp-server",
|
|
5803
|
-
version: "0.4.
|
|
5860
|
+
version: "0.4.122"
|
|
5804
5861
|
});
|
|
5805
5862
|
}
|
|
5806
5863
|
function registerAll(server) {
|