-
Notifications
You must be signed in to change notification settings - Fork 15
Expand file tree
/
Copy pathpackage.ts
More file actions
1486 lines (1356 loc) · 62.1 KB
/
Copy pathpackage.ts
File metadata and controls
1486 lines (1356 loc) · 62.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* CKAN Package (Dataset) tools
*/
import { z } from "zod";
import { ResponseFormat, ResponseFormatSchema, CkanTag, CkanResource, CkanPackage } from "../types.js";
import { makeCkanRequest, formatCkanError } from "../utils/http.js";
import { truncateText, truncateJson, formatDate, formatBytes, addDemoFooter, wrapUntrusted, safeUrlText, formatError, jsonToolResult, sanitizeInline } from "../utils/formatting.js";
import { getDatasetViewUrl, extractSourcePortal } from "../utils/url-generator.js";
import { resolveSearchQuery, stripAccents, hasAccents, isPlainMultiTermQuery, buildOrQuery, mayNeedTextWrapping } from "../utils/search.js";
import { getPortalHvdConfig, getPortalApiPath, requiresMultilingualNormalization } from "../utils/portal-config.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
/**
* Session cache for auto-detected parser mode on unknown portals.
* Key: normalized server URL. Value: true = needs text:(...) wrapping.
* Populated by probePortalParser() on first call to an unconfigured portal.
*/
const _portalParserCache = new Map<string, boolean>();
/**
* Pick two terms that actually occur in this catalog, for the parser probe below.
*
* They must be single words (a multi-word term would need quoting and change the
* parse) and neither rare nor saturating: a term matching most of the catalog makes
* `A OR B` indistinguishable from `A`, which is how the previous probe — hardcoded to
* "data OR dati" — read dati.comune.milano.it as healthy while `aria OR acqua` there
* returned 0 against 54 and 33 for the single terms.
*
* Tag facets first, since tags are in the catalog's own language; frequent title words
* as a fallback for portals that expose no tag facets (open.canada.ca) or too few
* (dati.regione.sicilia.it).
*/
async function pickProbeTerms(serverUrl: string): Promise<[string, string] | null> {
const facetRes = await makeCkanRequest<any>(serverUrl, 'package_search', {
q: '*:*',
rows: 0,
'facet.field': '["tags"]',
'facet.limit': 100
}).catch(() => null);
const total: number = facetRes?.count ?? 0;
if (!total) return null;
const items: Array<{ name?: string; count?: number }> =
facetRes?.search_facets?.tags?.items ?? [];
const usable = items
.filter(i => typeof i.name === 'string' && !/\s/.test(i.name))
.filter(i => (i.count ?? 0) >= total * 0.005 && (i.count ?? 0) <= total * 0.3)
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0));
if (usable.length >= 2) return [usable[0].name!, usable[1].name!];
const sampleRes = await makeCkanRequest<any>(serverUrl, 'package_search', {
q: '*:*',
rows: 25
}).catch(() => null);
const titles: string[] = (sampleRes?.results ?? []).map((r: any) => r?.title ?? '');
const freq = new Map<string, number>();
for (const word of titles.join(' ').toLowerCase().split(/[^\p{L}]+/u)) {
if (word.length > 4) freq.set(word, (freq.get(word) ?? 0) + 1);
}
const top = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 2);
return top.length === 2 ? [top[0][0], top[1][0]] : null;
}
/**
* Does this portal need `text:(...)` wrapping to honour a boolean query?
*
* `package_search` hands a colon-free query to Solr's dismax parser with `q.op=AND`
* (ckan/lib/search/query.py), and dismax has no boolean syntax: `A OR B` collapses to
* `A AND B`. A colon takes the query off dismax, which is what the wrapper exploits.
* That is CKAN's own default, so most portals need it — but not all: on
* data.stadt-zuerich.ch the catch-all `text` field returns 0 for every query, and
* wrapping there loses everything.
*
* So: an `A OR B` that returns fewer hits than `A` or `B` alone is not being honoured,
* and the wrapper is the fix only if the wrapped form actually returns more.
* Four rows=0 counts, cached per portal for the session, negative verdicts included.
* Callers must only reach here for queries that carry a boolean operator — nothing
* else is ever wrapped, so nothing else needs to pay for this.
*/
async function probePortalParser(serverUrl: string): Promise<boolean> {
const key = serverUrl.replace(/\/$/, '').toLowerCase();
if (_portalParserCache.has(key)) return _portalParserCache.get(key)!;
let needsText = false;
const terms = await pickProbeTerms(serverUrl).catch(() => null);
if (terms) {
const [a, b] = terms;
const count = async (q: string): Promise<number | null> => {
const res = await makeCkanRequest<any>(serverUrl, 'package_search', { q, rows: 0 })
.catch(() => null);
return typeof res?.count === 'number' ? res.count : null;
};
const [ca, cb, cOr, cText] = await Promise.all([
count(a),
count(b),
count(`${a} OR ${b}`),
count(`text:(${a} OR ${b})`)
]);
if (ca !== null && cb !== null && cOr !== null && cText !== null) {
const booleanIgnored = cOr < Math.max(ca, cb);
needsText = booleanIgnored && cText > cOr;
}
}
_portalParserCache.set(key, needsText);
return needsText;
}
type RelevanceWeights = {
title: number;
notes: number;
tags: number;
organization: number;
/**
* Weight for dct:rightsHolder (CKAN field `holder_name`) — the actual owner of the dataset.
* On federated catalogs (e.g. dati.gov.it), `organization` is the harvesting catalog, NOT the
* data owner. A query like "datasets from Comune di Lecce" must match `holder_name`, otherwise
* datasets owned by Lecce but harvested via Regione Puglia score 0 on the owner field.
*/
holder: number;
/**
* Weight for dct:publisher (CKAN field `publisher_name`) — the agent who published the dataset.
* Often equal to holder, but sometimes a technical role (e.g. "Redazione OD"). Kept separate
* from `holder` so it can be weighted lower to avoid noise.
*/
publisher: number;
};
type RelevanceBreakdown = {
title: number;
notes: number;
tags: number;
organization: number;
holder: number;
publisher: number;
total: number;
};
const DEFAULT_RELEVANCE_WEIGHTS: RelevanceWeights = {
title: 4,
notes: 2,
tags: 3,
organization: 1,
holder: 4,
publisher: 2
};
const QUERY_STOPWORDS = new Set([
"a",
"an",
"the",
"and",
"or",
"but",
"in",
"on",
"at",
"to",
"for",
"of",
"with",
"by",
"from",
"as",
"is",
"was",
"are",
"were",
"be",
"been",
"being",
"have",
"has",
"had",
"do",
"does",
"did",
"will",
"would",
"could",
"should",
"may",
"might",
"must",
"can",
"this",
"that",
"these",
"those"
]);
export const extractQueryTerms = (query: string): string[] => {
const matches = query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
const terms = matches.filter((term) => term.length > 1 && !QUERY_STOPWORDS.has(term));
return Array.from(new Set(terms));
};
export const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const textMatchesTerms = (text: string | undefined, terms: string[]): boolean => {
if (!text || terms.length === 0) return false;
const normalized = text.toLowerCase().replace(/_/g, " ");
return terms.some((term) => new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(normalized));
};
export const scoreTextField = (text: string | undefined, terms: string[], weight: number): number => {
return textMatchesTerms(text, terms) ? weight : 0;
};
/**
* Read a DCAT-AP_IT field (e.g. `holder_name`, `publisher_name`) from a CKAN dataset.
*
* On Italian DCAT-AP_IT portals (notably dati.gov.it via ckanext-dcatapit), `package_search`
* results expose DCAT fields in TWO places that often disagree:
*
* 1. Inside `extras[]` as `{ key, value }` pairs → this is the authoritative DCAT-AP_IT
* source: `dct:rightsHolder` ends up under `extras[].key === "holder_name"`,
* `dct:publisher` under `extras[].key === "publisher_name"`. These are the values the
* data publisher set, mapped from the upstream RDF.
*
* 2. As root-level fields (`dataset.holder_name`, `dataset.publisher_name`) → on aggregator
* catalogs, ckanext-dcatapit "promotes" the organization metadata into these root fields
* during harvesting. For datasets harvested via a regional/GAL/Unione catalog, the root
* value reflects the HARVESTER, not the data owner. Example on dati.gov.it: dataset
* `defibrillatori-esterni` has `extras.holder_name = "Comune di Mesagne"` (correct) but
* root `holder_name = "GAL Terra dei Messapi"` (wrong owner — that's the harvester).
*
* This helper prefers `extras[]` (DCAT-AP_IT truth) and falls back to the root field only
* when extras don't carry the key. The fallback is important for non-DCAT-AP_IT CKAN portals
* (e.g. data.gov, open.canada.ca) where root-level holder/publisher are correct.
*/
export const readDcatExtra = (dataset: CkanPackage, key: "holder_name" | "publisher_name"): string => {
const extras = Array.isArray(dataset.extras) ? dataset.extras : [];
for (const e of extras) {
if (e && typeof e === "object" && (e as { key?: unknown }).key === key) {
const value = (e as { value?: unknown }).value;
if (typeof value === "string" && value.length > 0) return value;
}
}
const rootValue = dataset[key];
return typeof rootValue === "string" ? rootValue : "";
};
export const scoreDatasetRelevance = (
query: string,
dataset: CkanPackage,
weights: RelevanceWeights = DEFAULT_RELEVANCE_WEIGHTS
): { total: number; breakdown: RelevanceBreakdown; terms: string[] } => {
const terms = extractQueryTerms(query);
const titleText = dataset.title || dataset.name || "";
const notesText = dataset.notes || "";
const orgText = dataset.organization?.title || dataset.organization?.name || dataset.owner_org || "";
const holderText = readDcatExtra(dataset, "holder_name");
const publisherText = readDcatExtra(dataset, "publisher_name");
const breakdown = {
title: scoreTextField(titleText, terms, weights.title),
notes: scoreTextField(notesText, terms, weights.notes),
tags: 0,
organization: scoreTextField(orgText, terms, weights.organization),
holder: scoreTextField(holderText, terms, weights.holder),
publisher: scoreTextField(publisherText, terms, weights.publisher),
total: 0
};
if (Array.isArray(dataset.tags) && dataset.tags.length > 0 && terms.length > 0) {
const tagMatch = dataset.tags.some((tag: CkanTag) => {
const tagValue = typeof tag === "string" ? tag : tag?.name;
return textMatchesTerms(tagValue, terms);
});
breakdown.tags = tagMatch ? weights.tags : 0;
}
breakdown.total =
breakdown.title +
breakdown.notes +
breakdown.tags +
breakdown.organization +
breakdown.holder +
breakdown.publisher;
return { total: breakdown.total, breakdown, terms };
};
export const parseAccessServices = (resource: CkanResource): Array<Record<string, unknown>> => {
if (!resource || resource.access_services == null) return [];
const raw = resource.access_services;
if (Array.isArray(raw)) return raw;
if (typeof raw === "string" && raw.trim().length > 0) {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
};
export const extractServiceEndpoints = (services: Array<Record<string, unknown>>): string[] => {
const endpoints: string[] = [];
for (const service of services) {
const urls = service.endpoint_url;
if (Array.isArray(urls)) {
for (const url of urls) {
if (typeof url === "string" && url.trim().length > 0) endpoints.push(url.trim());
}
} else if (typeof urls === "string" && urls.trim().length > 0) {
endpoints.push(urls.trim());
}
}
return Array.from(new Set(endpoints));
};
export const resolveDownloadUrl = (resource: CkanResource): string | null => {
if (!resource) return null;
const downloadUrl = typeof resource.download_url === "string" ? resource.download_url.trim() : "";
const accessUrl = typeof resource.access_url === "string" ? resource.access_url.trim() : "";
const url = typeof resource.url === "string" ? resource.url.trim() : "";
return downloadUrl || accessUrl || url || null;
};
export const enrichPackageShowResult = (result: CkanPackage): CkanPackage => ({
...result,
metadata_harvested_at: result.metadata_modified ?? null,
resources: Array.isArray(result.resources)
? result.resources.map((resource: CkanResource) => {
const accessServices = parseAccessServices(resource);
const accessEndpoints = extractServiceEndpoints(accessServices);
const effectiveDownloadUrl = resolveDownloadUrl(resource);
if (accessEndpoints.length === 0 && !effectiveDownloadUrl) return resource;
return {
...resource,
...(accessEndpoints.length > 0 ? { access_service_endpoints: accessEndpoints } : {}),
...(effectiveDownloadUrl ? { effective_download_url: effectiveDownloadUrl } : {})
};
})
: result.resources
});
export const formatPackageShowMarkdown = (result: CkanPackage, serverUrl: string): string => {
let markdown = `# Dataset: ${sanitizeInline(result.title || result.name)}\n\n`;
markdown += `**Server**: ${serverUrl}\n`;
markdown += `**Link**: ${getDatasetViewUrl(serverUrl, result)}\n`;
markdown += `**Full JSON metadata**: ${serverUrl.replace(/\/$/, '')}${getPortalApiPath(serverUrl)}/package_show?id=${encodeURIComponent(result.id)}\n\n`;
markdown += `## Basic Information\n\n`;
markdown += `- **ID**: \`${sanitizeInline(result.id)}\`\n`;
markdown += `- **Name**: \`${sanitizeInline(result.name)}\`\n`;
if (result.author) markdown += `- **Author**: ${sanitizeInline(result.author)}\n`;
if (result.author_email) markdown += `- **Author Email**: ${sanitizeInline(result.author_email)}\n`;
if (result.maintainer) markdown += `- **Maintainer**: ${sanitizeInline(result.maintainer)}\n`;
if (result.maintainer_email) markdown += `- **Maintainer Email**: ${sanitizeInline(result.maintainer_email)}\n`;
markdown += `- **License**: ${sanitizeInline(result.license_title || result.license_id || 'Not specified')}\n`;
markdown += `- **State**: ${sanitizeInline(result.state)}\n`;
markdown += `- **Created**: ${formatDate(result.metadata_created)}\n`;
if (result.issued) {
markdown += `- **Issued**: ${formatDate(result.issued)}\n`;
} else {
markdown += `- **Issued**: (missing in CKAN; downstream RDF may default to metadata_created, which is a record timestamp)\n`;
}
if (result.modified) markdown += `- **Modified (Content)**: ${formatDate(result.modified)}\n`;
markdown += `- **Metadata Modified (Record)**: ${formatDate(result.metadata_modified)}\n`;
// DCAT-AP fields returned natively by package_show but not otherwise surfaced.
// holder/publisher via readDcatExtra (extras override root on aggregators); the rest read root.
const holderName = readDcatExtra(result, "holder_name");
if (holderName) markdown += `- **Rights Holder (dct:rightsHolder)**: ${sanitizeInline(holderName)}\n`;
const publisherName = readDcatExtra(result, "publisher_name");
if (publisherName) markdown += `- **Publisher (dct:publisher)**: ${sanitizeInline(publisherName)}\n`;
const dcatField = (key: string): string =>
typeof result[key] === "string" ? (result[key] as string) : "";
const frequency = dcatField("frequency");
if (frequency) markdown += `- **Update Frequency (dct:accrualPeriodicity)**: ${sanitizeInline(frequency)}\n`;
const language = dcatField("language");
if (language) markdown += `- **Language (dct:language)**: ${sanitizeInline(language)}\n`;
const accessRights = dcatField("access_rights");
if (accessRights) markdown += `- **Access Rights (dct:accessRights)**: ${sanitizeInline(accessRights)}\n`;
markdown += `\n`;
if (result.organization) {
markdown += `## Organization\n\n`;
markdown += `- **Name**: ${sanitizeInline(result.organization.title || result.organization.name)}\n`;
markdown += `- **ID**: \`${sanitizeInline(result.organization.id)}\`\n\n`;
}
if (result.notes) {
markdown += `## Description\n\n${wrapUntrusted(result.notes)}\n\n`;
}
if (result.tags && result.tags.length > 0) {
markdown += `## Tags\n\n`;
markdown += result.tags.map((t: CkanTag) => `- ${sanitizeInline(t.name)}`).join('\n') + '\n\n';
}
if (result.groups && result.groups.length > 0) {
markdown += `## Groups\n\n`;
for (const group of result.groups) {
markdown += `- **${sanitizeInline(group.title || group.name)}** (\`${sanitizeInline(group.name)}\`)\n`;
}
markdown += '\n';
}
if (result.resources && result.resources.length > 0) {
markdown += `## Resources (${result.resources.length})\n\n`;
for (const resource of result.resources) {
markdown += `### ${sanitizeInline(resource.name || 'Unnamed Resource')}\n\n`;
markdown += `- **ID**: \`${sanitizeInline(resource.id)}\`\n`;
markdown += `- **Format**: ${sanitizeInline(resource.format || 'Unknown')}\n`;
if (resource.description) markdown += `- **Description**:\n\n${wrapUntrusted(resource.description)}\n\n`;
markdown += `- **URL**: ${safeUrlText(resource.url)}\n`;
const accessServices = parseAccessServices(resource);
const accessEndpoints = extractServiceEndpoints(accessServices);
if (accessEndpoints.length > 0) {
markdown += `- **Access Service Endpoints**: ${accessEndpoints.join(', ')}\n`;
}
const effectiveDownloadUrl = resolveDownloadUrl(resource);
if (effectiveDownloadUrl) {
markdown += `- **Effective Download URL**: ${safeUrlText(effectiveDownloadUrl)}\n`;
}
if (resource.size) {
const formatBytes = (bytes: number) => {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
markdown += `- **Size**: ${formatBytes(resource.size)}\n`;
}
if (resource.mimetype) markdown += `- **MIME Type**: ${resource.mimetype}\n`;
markdown += `- **Created**: ${formatDate(resource.created)}\n`;
if (resource.last_modified) markdown += `- **Modified**: ${formatDate(resource.last_modified)}\n`;
if (resource.datastore_active === true) {
markdown += `- **DataStore**: ✅ Available\n`;
} else if (resource.datastore_active === false) {
markdown += `- **DataStore**: ❌ Not available\n`;
} else {
markdown += `- **DataStore**: ❓ Not reported by portal\n`;
}
markdown += `- **Full JSON metadata**: ${serverUrl.replace(/\/$/, '')}${getPortalApiPath(serverUrl)}/resource_show?id=${encodeURIComponent(resource.id)}\n`;
markdown += '\n';
}
}
if (result.extras && result.extras.length > 0) {
markdown += `## Extra Fields\n\n`;
for (const extra of result.extras) {
markdown += `- **${sanitizeInline(extra.key)}**: ${sanitizeInline(extra.value)}\n`;
}
markdown += '\n';
}
return markdown;
};
export function resolvePageParams(
page: number | undefined,
pageSize: number,
start: number,
rows: number
): { effectiveStart: number; effectiveRows: number } {
if (page !== undefined) {
return { effectiveStart: (page - 1) * pageSize, effectiveRows: pageSize };
}
return { effectiveStart: start, effectiveRows: rows };
}
/**
* Resolve a single Solr NOW expression to an ISO 8601 datetime string.
* Handles: NOW, NOW±Nunit, NOW/DAY, NOW/MONTH.
* Used to convert date math for CKAN extra fields (issued, modified) that
* are not native Solr fields and do not support NOW syntax.
*/
function resolveNowExpr(nowExpr: string): string {
const upper = nowExpr.toUpperCase();
const now = new Date();
if (upper === 'NOW') return now.toISOString();
const floorMatch = upper.match(/^NOW\/(DAY|MONTH)$/);
if (floorMatch) {
if (floorMatch[1] === 'DAY') now.setUTCHours(0, 0, 0, 0);
else { now.setUTCDate(1); now.setUTCHours(0, 0, 0, 0); }
return now.toISOString();
}
const arithMatch = upper.match(/^NOW([+-])(\d+)(YEARS?|MONTHS?|DAYS?|HOURS?|MINUTES?|SECONDS?)$/);
if (arithMatch) {
const sign = arithMatch[1] === '+' ? 1 : -1;
const n = parseInt(arithMatch[2]);
const unit = arithMatch[3].replace(/S$/, '');
switch (unit) {
case 'YEAR': now.setUTCFullYear(now.getUTCFullYear() + sign * n); break;
case 'MONTH': now.setUTCMonth(now.getUTCMonth() + sign * n); break;
case 'DAY': now.setUTCDate(now.getUTCDate() + sign * n); break;
case 'HOUR': now.setUTCHours(now.getUTCHours() + sign * n); break;
case 'MINUTE': now.setUTCMinutes(now.getUTCMinutes() + sign * n); break;
case 'SECOND': now.setUTCSeconds(now.getUTCSeconds() + sign * n); break;
}
return now.toISOString();
}
return nowExpr;
}
/**
* Convert NOW date math to ISO dates for CKAN extra fields (issued, modified).
* These fields are not native Solr date fields and do not support NOW syntax.
* Leaves metadata_modified and metadata_created untouched (they are native Solr fields).
*/
function convertNowForExtraFields(str: string): string {
return str.replace(
/\b(issued|modified):\[([^\]]*)\]/gi,
(_match, field, range) => {
const converted = range.replace(
/\bNOW(?:[+-]\d+(?:YEARS?|MONTHS?|DAYS?|HOURS?|MINUTES?|SECONDS?)|\/(?:DAY|MONTH))?\b/gi,
(now: string) => resolveNowExpr(now)
);
return `${field}:[${converted}]`;
}
);
}
/**
* Normalize a package from portals with non-standard field structure (e.g. data.europa.eu).
* Falls back to translation fields when title/name are null, and handles organization.title
* as a multilingual object.
*/
function normalizePackage(pkg: CkanPackage): CkanPackage {
const translation = pkg.translation as Record<string, Record<string, string>> | undefined;
if (!pkg.title && translation) {
pkg = { ...pkg, title: translation.en?.title || Object.values(translation)[0]?.title };
}
if (!pkg.name) {
pkg = { ...pkg, name: pkg.id };
}
if (pkg.organization?.title && typeof pkg.organization.title === 'object') {
const titleObj = pkg.organization.title as Record<string, string>;
pkg = { ...pkg, organization: { ...pkg.organization, title: titleObj.en || Object.values(titleObj)[0] } };
}
if (pkg.tags) {
pkg = { ...pkg, tags: pkg.tags.map((t: CkanTag) => t.name ? t : { ...t, name: t.id || t['display-name'] || '' }) };
}
return pkg;
}
/**
* Compact JSON representation of package_search results.
* Keeps only essential fields to reduce token usage (~80% reduction).
*/
export function compactSearchResult(result: any, serverUrl?: string): object {
return {
count: result.count,
results: (result.results || []).map((rawPkg: CkanPackage) => {
const pkg = serverUrl && requiresMultilingualNormalization(serverUrl) ? normalizePackage(rawPkg) : rawPkg;
return {
id: pkg.id,
name: pkg.name,
title: pkg.title || pkg.name,
notes: pkg.notes ? pkg.notes.substring(0, 200) + (pkg.notes.length > 200 ? '...' : '') : null,
organization: pkg.organization?.title || pkg.organization?.name || null,
tags: (pkg.tags || []).map((t: CkanTag) => t.name),
num_resources: pkg.num_resources ?? 0,
metadata_modified: pkg.metadata_modified,
...(serverUrl ? { view_url: getDatasetViewUrl(serverUrl, pkg) } : {})
};
}),
...(result.facets && Object.keys(result.facets).length > 0 ? { facets: result.facets } : {}),
...(result.search_facets && Object.keys(result.search_facets).length > 0 ? { search_facets: result.search_facets } : {})
};
}
/**
* Compact JSON representation of package_show results.
* Keeps metadata + slim resources, drops extras/relationships/tracking.
*/
export function compactPackageShow(result: CkanPackage, serverUrl?: string): object {
return {
id: result.id,
name: result.name,
title: result.title || result.name,
notes: result.notes || null,
organization: result.organization ? {
name: result.organization.name,
title: result.organization.title
} : null,
tags: (result.tags || []).map((t: CkanTag) => t.name),
state: result.state,
license_title: result.license_title || result.license_id || null,
metadata_created: result.metadata_created,
metadata_modified: result.metadata_modified,
issued: result.issued || null,
modified: result.modified || null,
author: result.author || null,
maintainer: result.maintainer || null,
frequency: result.frequency || null,
language: result.language || null,
publisher_name: result.publisher_name || null,
holder_name: result.holder_name || null,
hvd_category: result.hvd_category || null,
applicable_legislation: result.applicable_legislation || null,
resources: (result.resources || []).map((r: CkanResource) => ({
id: r.id,
name: r.name || null,
format: r.format || null,
url: r.url || null,
size: r.size || null,
datastore_active: r.datastore_active ?? null,
created: r.created || null,
last_modified: r.last_modified || null,
...(serverUrl ? { api_json_url: `${serverUrl.replace(/\/$/, '')}${getPortalApiPath(serverUrl)}/resource_show?id=${r.id}` } : {})
})),
...(serverUrl ? {
view_url: getDatasetViewUrl(serverUrl, result),
api_json_url: `${serverUrl.replace(/\/$/, '')}${getPortalApiPath(serverUrl)}/package_show?id=${result.id}`
} : {})
};
}
export function registerPackageTools(server: McpServer) {
/**
* Search for datasets on a CKAN server
*/
server.registerTool(
"ckan_package_search",
{
title: "Search CKAN Datasets",
description: `Search for datasets (packages) on a CKAN server using Solr query syntax.
Supports full Solr search capabilities including filters, facets, and sorting.
Use this to discover datasets matching specific criteria.
Note on parser behavior:
Some CKAN portals use a restrictive default query parser that can break long OR queries.
For those portals, this tool may force the query into 'text:(...)' based on per-portal config.
You can override with 'query_parser' to force or disable this behavior per request.
Important - Date field semantics:
- issued: publisher's content publish date when available (best proxy for "created/published")
- modified: publisher's content update date when available
- metadata_created: CKAN record creation timestamp (publish time on source portals,
harvest time on aggregators; fallback for "created" if issued missing)
- metadata_modified: CKAN record update timestamp (publish time on source portals,
harvest time on aggregators; use for "updated/modified in last X")
Natural language mapping (important for tool callers):
- "created"/"published" -> prefer issued; fallback to metadata_created
- "updated"/"modified" -> prefer modified; fallback to metadata_modified
- For "recent in last X", consider using content_recent (issued with metadata_created fallback)
Content-recent helper:
- content_recent: if true, rewrites the query to use issued with a fallback to
metadata_created when issued is missing.
- content_recent_days: window for content_recent (default 30 days).
Args:
- server_url (string): Base URL of CKAN server (e.g., "https://dati.gov.it/opendata")
- q (string): Search query using Solr syntax (default: "*:*" for all)
- fq (string): Filter query (e.g., "organization:comune-palermo")
IMPORTANT — Solr fq syntax rules:
1. OR inside a single field: use field:(val1 OR val2), NOT field:val1 OR field:val2.
Wrong: fq=type:"A" OR type:"B" → silently ignored, returns entire catalog.
Right: fq=type:("A" OR "B")
2. CKAN extras fields are indexed as extras_fieldname, not fieldname.
e.g. to filter on extra field "hvd_category" use fq=extras_hvd_category:"<value>"
- rows (number): Number of results to return (default: 10, max: 1000)
- start (number): Offset for pagination (default: 0)
- page (number): Page number (1-based); alias for start. Overrides start if provided.
- page_size (number): Results per page when using page (default: 10, max: 1000)
- sort (string): Sort field and direction (e.g., "metadata_modified desc")
- facet_field (array): Fields to facet on (e.g., ["organization", "tags"])
- facet_limit (number): Max facet values per field (default: 50)
- include_drafts (boolean): Include draft datasets (default: false)
- query_parser ('default' | 'text'): Override search parser behavior
- response_format ('markdown' | 'json'): Output format
Returns:
Search results with:
- count: Number of results found
- results: Array of dataset objects
- facets: Facet counts (if facet_field specified)
- search_facets: Detailed facet information
Query Syntax (parameter q):
Boolean operators:
- AND / &&: "water AND climate"
- OR / ||: "health OR sanità"
- NOT / !: "data NOT personal"
- +required -excluded: "+title:water -title:sea"
- Grouping: "(title:water OR title:climate) AND tags:environment"
Wildcards:
- *: "title:environment*" (matches environmental, environments, etc.)
- Note: Left truncation (*water) not supported
Fuzzy search (edit distance):
- ~: "title:rest~" or "title:rest~1" (finds "test", "best", "rest")
Proximity search (words within N positions):
- "phrase"~N: "title:\"climate change\"~5"
Range queries:
- Inclusive [a TO b]: "num_resources:[5 TO 10]"
- Exclusive {a TO b}: "num_resources:{0 TO 100}"
- One side open: "metadata_modified:[2024-01-01T00:00:00Z TO *]"
Date math:
- NOW-1YEAR, NOW-6MONTHS, NOW-7DAYS, NOW-1HOUR
- NOW/DAY, NOW/MONTH (round down)
- Combined: "metadata_modified:[NOW-2MONTHS TO NOW]"
- Example: "metadata_created:[NOW-1YEAR TO *]"
- IMPORTANT: NOW syntax works on metadata_modified and metadata_created fields
- For 'modified' and 'issued' fields, NOW syntax is auto-converted to ISO dates
- Manual ISO dates always work: "modified:[2026-01-15T00:00:00Z TO *]"
Field existence:
- Exists: "field:*" or "field:[* TO *]"
- Not exists: "NOT field:*" or "-field:*"
Boosting (relevance scoring):
- Boost term: "title:water^2 OR notes:water" (title matches score higher)
- Constant score: "title:water^=1.5"
Examples:
- Search all: { q: "*:*" }
- By tag: { q: "tags:sanità" }
- Boolean: { q: "(title:water OR title:climate) AND NOT title:sea" }
- Wildcard: { q: "title:environment*" }
- Fuzzy: { q: "title:health~2" }
- Proximity: { q: "notes:\"open data\"~3" }
- Date range: { q: "metadata_modified:[2024-01-01T00:00:00Z TO 2024-12-31T23:59:59Z]" }
- Date math: { q: "metadata_modified:[NOW-6MONTHS TO *]" }
- Date math (auto-converted): { q: "modified:[NOW-30DAYS TO NOW]" }
- Published in 2025 (content date): { fq: "issued:[2025-01-01T00:00:00Z TO 2025-12-31T23:59:59Z]" }
- First appeared on portal in 2025: { fq: "metadata_created:[2025-01-01T00:00:00Z TO 2025-12-31T23:59:59Z]" }
- Recent content (issued w/ fallback): { q: "*:*", content_recent: true, content_recent_days: 180 }
- Field exists: { q: "organization:* AND num_resources:[1 TO *]" }
- Boosting: { q: "title:climate^2 OR notes:climate" }
- Filter org: { fq: "organization:regione-siciliana" }
- Filter extras field (correct): { fq: "extras_hvd_category:\"http://data.europa.eu/bna/c_ac64a52d\"" }
- Filter extras OR (correct): { fq: "extras_hvd_category:(\"http://data.europa.eu/bna/c_ac64a52d\" OR \"http://data.europa.eu/bna/c_dd313021\")" }
- Get facets: { facet_field: ["organization"], rows: 0 }
Query language:
Before searching a portal, check its locale via ckan_status_show (field: "Portal Locale" / locale_default).
Translate query terms to the portal's language — searching in English on a non-English portal returns 0 results.
Examples: locale "it" → Italian terms; "uk_UA" → Ukrainian (Cyrillic); "fr_FR" → French.
Exception: multilingual portals (e.g. data.europa.eu, open.canada.ca) accept EN + native terms joined with OR.
Typical workflow: ckan_status_show (check locale) → ckan_package_search (query in portal's language) → ckan_package_show (get full metadata + resource IDs) → ckan_datastore_search (query tabular data)`,
inputSchema: z.object({
server_url: z.string()
.url("Must be a valid URL")
.describe("Base URL of the CKAN server"),
q: z.string()
.optional()
.default("*:*")
.describe("Search query in Solr syntax"),
fq: z.string()
.optional()
.describe("Filter query in Solr syntax; applied after scoring, does not affect relevance. CKAN extras fields use prefix 'extras_' (e.g. extras_hvd_category). For OR on same field use field:(val1 OR val2), never field:val1 OR field:val2 (silently breaks). Examples: 'organization:comune-palermo', 'res_format:CSV', 'extras_hvd_category:(\"uri1\" OR \"uri2\")'."),
rows: z.coerce.number()
.int()
.min(0)
.max(1000)
.optional()
.default(10)
.describe("Number of results to return"),
start: z.coerce.number()
.int()
.min(0)
.optional()
.default(0)
.describe("Offset for pagination"),
sort: z.string()
.optional()
.describe("Sort field and direction (e.g., 'metadata_modified desc')"),
facet_field: z.array(z.string())
.optional()
.describe("Fields to facet on"),
facet_limit: z.coerce.number()
.int()
.min(1)
.optional()
.default(50)
.describe("Maximum facet values per field"),
page: z.coerce.number()
.int()
.min(1)
.optional()
.describe("Page number (1-based); alias for start. Overrides start if provided."),
page_size: z.coerce.number()
.int()
.min(1)
.max(1000)
.optional()
.default(10)
.describe("Results per page when using page (default: 10)"),
include_drafts: z.boolean()
.optional()
.default(false)
.describe("Include draft datasets"),
content_recent: z.boolean()
.optional()
.default(false)
.describe("Use issued date with fallback to metadata_created for recent content"),
content_recent_days: z.coerce.number()
.int()
.min(1)
.optional()
.default(30)
.describe("Day window for content_recent (default 30)"),
query_parser: z.enum(["default", "text"])
.optional()
.describe("Override search parser ('text' forces text:(...) on non-fielded queries)"),
response_format: ResponseFormatSchema
}).strict(),
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true
}
},
async (params) => {
try {
const userQuery = params.q;
let query = userQuery;
let effectiveSort = params.sort;
if (params.content_recent) {
const days = params.content_recent_days ?? 30;
const daysAgo = new Date();
daysAgo.setUTCDate(daysAgo.getUTCDate() - days);
const daysAgoIso = daysAgo.toISOString();
const nowIso = new Date().toISOString();
const recentClause = `(issued:[${daysAgoIso} TO ${nowIso}]) OR (-issued:* AND metadata_created:[NOW-${days}DAYS TO NOW])`;
query = userQuery && userQuery !== "*:*" ? `(${userQuery}) AND (${recentClause})` : recentClause;
if (!effectiveSort) effectiveSort = "issued desc, metadata_created desc";
}
// Only a boolean query can benefit from text:(...) wrapping, so only a boolean
// query pays for the probe. Every portal is probed, configured ones included:
// the values that used to live in portals.json went stale.
let parserOverride = params.query_parser;
if (!parserOverride && mayNeedTextWrapping(query)) {
const needsText = await probePortalParser(params.server_url);
if (needsText) parserOverride = "text";
}
const { effectiveQuery } = resolveSearchQuery(
params.server_url,
query,
parserOverride
);
const { effectiveRows, effectiveStart } = resolvePageParams(params.page, params.page_size, params.start, params.rows);
const apiParams: Record<string, any> = {
q: convertNowForExtraFields(effectiveQuery),
rows: effectiveRows,
start: effectiveStart,
include_private: params.include_drafts
};
if (params.fq) apiParams.fq = convertNowForExtraFields(params.fq);
if (effectiveSort) apiParams.sort = effectiveSort;
if (params.facet_field && params.facet_field.length > 0) {
apiParams['facet.field'] = JSON.stringify(params.facet_field);
apiParams['facet.limit'] = params.facet_limit;
}
let result = await makeCkanRequest<any>(
params.server_url,
'package_search',
apiParams
);
let accentFallbackUsed = false;
if (result.count === 0 && hasAccents(params.q)) {
const strippedQuery = stripAccents(params.q);
const { effectiveQuery: strippedEffective } = resolveSearchQuery(
params.server_url,
strippedQuery,
params.query_parser
);
const fallbackResult = await makeCkanRequest<any>(
params.server_url,
'package_search',
{ ...apiParams, q: strippedEffective }
);
if (fallbackResult.count > 0) {
result = fallbackResult;
accentFallbackUsed = true;
}
}
if (params.response_format === ResponseFormat.JSON) {
const compact = compactSearchResult(result, params.server_url);
return {
content: [{ type: "text", text: truncateJson(compact) }]
};
}
// HVD note: only on synthesis queries (q=*:* + facets or rows=0) in markdown mode
let hvdNote = '';
const isSynthesisQuery = (params.q === '*:*' || params.q === undefined) &&
(effectiveRows === 0 ||
(params.facet_field && params.facet_field.some((f) =>
['organization', 'tags', 'groups', 'res_format'].includes(f)
)));
if (isSynthesisQuery) {
const hvdConfig = getPortalHvdConfig(params.server_url);
if (hvdConfig) {
try {
const hvdResult = await makeCkanRequest<any>(
params.server_url,
'package_search',
{ q: `${hvdConfig.category_field}:*`, rows: 0 }
);
if (hvdResult.count > 0) {
hvdNote = `> **High Value Datasets (HVD)**: This portal contains **${hvdResult.count} datasets** classified as High Value Datasets under EU Regulation 2023/138.\n\n`;
}
} catch {
// silently skip if HVD query fails
}
}
}
// Markdown format
let markdown = `# CKAN Package Search Results
**Server**: ${params.server_url}
**Query**: ${userQuery}
${params.content_recent ? `**Content Recent**: last ${params.content_recent_days ?? 30} days (issued with metadata_created fallback)\n` : ''}
${effectiveQuery !== userQuery ? `**Effective Query**: ${effectiveQuery}\n` : ''}
${accentFallbackUsed ? `**Note**: Original query returned 0 results; retried with accent-stripped query "${stripAccents(params.q)}".\n` : ''}
${params.fq ? `**Filter**: ${params.fq}\n` : ''}
**Total Results**: ${result.count}
**Showing**: ${result.results.length} results (from ${effectiveStart})
${hvdNote}`;
// Show facets if available
if (result.facets && Object.keys(result.facets).length > 0) {
markdown += `## Facets\n\n`;
for (const [field, values] of Object.entries(result.facets)) {
markdown += `### ${field}\n\n`;
const facetValues = values as Record<string, number>;
const sorted = Object.entries(facetValues)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
for (const [value, count] of sorted) {
markdown += `- **${value}**: ${count}\n`;
}
if (Object.keys(facetValues).length > sorted.length) {
markdown += `\nNote: showing top ${sorted.length} only. Use \`response_format: json\` or increase \`facet_limit\`.\n`;
} else {
markdown += `\nNote: showing top ${sorted.length} only. Use \`response_format: json\` for full list.\n`;
}
markdown += '\n';
}
}
// Show results
if (result.results && result.results.length > 0) {
markdown += `## Datasets\n\n`;
for (const rawPkg of result.results) {
const pkg = requiresMultilingualNormalization(params.server_url) ? normalizePackage(rawPkg) : rawPkg;
markdown += `### ${sanitizeInline(pkg.title || pkg.name)}\n\n`;
markdown += `- **ID**: \`${sanitizeInline(pkg.id)}\`\n`;
markdown += `- **Name**: \`${sanitizeInline(pkg.name)}\`\n`;
if (pkg.organization) {
markdown += `- **Organization**: ${sanitizeInline(pkg.organization.title || pkg.organization.name)}\n`;
}
if (pkg.notes) {
const notes = pkg.notes.substring(0, 200);
markdown += `- **Description**: ${notes}${pkg.notes.length > 200 ? '...' : ''}\n`;
}
if (pkg.tags && pkg.tags.length > 0) {
const tags = pkg.tags.slice(0, 5).map((t: CkanTag) => t.name).join(', ');
markdown += `- **Tags**: ${sanitizeInline(tags)}${pkg.tags.length > 5 ? ', ...' : ''}\n`;
}
markdown += `- **Resources**: ${pkg.num_resources || 0}\n`;
markdown += `- **Modified**: ${formatDate(pkg.metadata_modified)}\n`;
markdown += `- **Link**: ${getDatasetViewUrl(params.server_url, pkg)}\n\n`;
}
} else {
markdown += `No datasets found matching your query.\n`;
markdown += `\n> **Note**: No data was found on this portal. Do not use information from other sources to supplement this result.\n`;
if (isPlainMultiTermQuery(params.q)) {
// CKAN's dismax applies mm='2<-1 5<80%', so a plain multi-term query is
// already a partial match: spelling out OR relaxes it the rest of the way
// and, on portals that ignore boolean operators, switches parser too.
markdown += `\n> **Tip**: With several terms the portal requires most of them to match. Spelling out OR broadens the search:\n`;
markdown += `> \`q: "${buildOrQuery(params.q)}"\`\n`;
}
}