@aborruso/ckan-mcp-server 0.4.5 → 0.4.7

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 (32) hide show
  1. package/EXAMPLES.md +19 -0
  2. package/LOG.md +13 -0
  3. package/README.md +47 -37
  4. package/dist/index.js +435 -13
  5. package/dist/worker.js +130 -74
  6. package/openspec/changes/add-ckan-analyze-dataset-structure/proposal.md +17 -0
  7. package/openspec/changes/add-ckan-analyze-dataset-structure/specs/ckan-insights/spec.md +7 -0
  8. package/openspec/changes/add-ckan-analyze-dataset-structure/tasks.md +6 -0
  9. package/openspec/changes/add-ckan-analyze-dataset-updates/proposal.md +17 -0
  10. package/openspec/changes/add-ckan-analyze-dataset-updates/specs/ckan-insights/spec.md +7 -0
  11. package/openspec/changes/add-ckan-analyze-dataset-updates/tasks.md +6 -0
  12. package/openspec/changes/add-ckan-audit-tool/proposal.md +17 -0
  13. package/openspec/changes/add-ckan-audit-tool/specs/ckan-insights/spec.md +7 -0
  14. package/openspec/changes/add-ckan-audit-tool/tasks.md +6 -0
  15. package/openspec/changes/add-ckan-dataset-insights/proposal.md +17 -0
  16. package/openspec/changes/add-ckan-dataset-insights/specs/ckan-insights/spec.md +7 -0
  17. package/openspec/changes/add-ckan-dataset-insights/tasks.md +6 -0
  18. package/openspec/changes/archive/2026-01-10-add-ckan-find-relevant-datasets/proposal.md +17 -0
  19. package/openspec/changes/archive/2026-01-10-add-ckan-find-relevant-datasets/specs/ckan-insights/spec.md +7 -0
  20. package/openspec/changes/archive/2026-01-10-add-ckan-find-relevant-datasets/tasks.md +6 -0
  21. package/openspec/changes/update-search-parser-config/proposal.md +13 -0
  22. package/openspec/changes/update-search-parser-config/specs/ckan-insights/spec.md +11 -0
  23. package/openspec/changes/update-search-parser-config/specs/ckan-search/spec.md +11 -0
  24. package/openspec/changes/update-search-parser-config/tasks.md +6 -0
  25. package/openspec/project.md +9 -7
  26. package/openspec/specs/ckan-insights/spec.md +19 -0
  27. package/openspec/specs/cloudflare-deployment/spec.md +344 -0
  28. package/package.json +1 -1
  29. /package/openspec/changes/{add-cloudflare-workers → archive/2026-01-10-add-cloudflare-workers}/design.md +0 -0
  30. /package/openspec/changes/{add-cloudflare-workers → archive/2026-01-10-add-cloudflare-workers}/proposal.md +0 -0
  31. /package/openspec/changes/{add-cloudflare-workers → archive/2026-01-10-add-cloudflare-workers}/specs/cloudflare-deployment/spec.md +0 -0
  32. /package/openspec/changes/{add-cloudflare-workers → archive/2026-01-10-add-cloudflare-workers}/tasks.md +0 -0
package/dist/index.js CHANGED
@@ -90,42 +90,168 @@ var portals_default = {
90
90
  "http://www.dati.gov.it/opendata",
91
91
  "http://dati.gov.it/opendata"
92
92
  ],
93
+ search: {
94
+ force_text_field: true
95
+ },
93
96
  dataset_view_url: "https://www.dati.gov.it/view-dataset/dataset?id={id}",
94
97
  organization_view_url: "https://www.dati.gov.it/view-dataset?organization={name}"
95
98
  }
96
99
  ],
97
100
  defaults: {
98
101
  dataset_view_url: "{server_url}/dataset/{name}",
99
- organization_view_url: "{server_url}/organization/{name}"
102
+ organization_view_url: "{server_url}/organization/{name}",
103
+ search: {
104
+ force_text_field: false
105
+ }
100
106
  }
101
107
  };
102
108
 
103
- // src/utils/url-generator.ts
109
+ // src/utils/portal-config.ts
104
110
  function normalizeUrl(url) {
105
111
  return url.replace(/\/$/, "");
106
112
  }
107
- function getDatasetViewUrl(serverUrl, pkg) {
113
+ function getPortalConfig(serverUrl) {
108
114
  const cleanServerUrl = normalizeUrl(serverUrl);
109
115
  const portal = portals_default.portals.find((p) => {
110
116
  const mainUrl = normalizeUrl(p.api_url);
111
117
  const aliases = (p.api_url_aliases || []).map(normalizeUrl);
112
118
  return mainUrl === cleanServerUrl || aliases.includes(cleanServerUrl);
113
119
  });
120
+ return portal || null;
121
+ }
122
+ function getPortalSearchConfig(serverUrl) {
123
+ const portal = getPortalConfig(serverUrl);
124
+ const defaults = portals_default.defaults?.search || {};
125
+ return {
126
+ force_text_field: portal?.search?.force_text_field ?? defaults.force_text_field ?? false
127
+ };
128
+ }
129
+ function normalizePortalUrl(serverUrl) {
130
+ return normalizeUrl(serverUrl);
131
+ }
132
+
133
+ // src/utils/url-generator.ts
134
+ function getDatasetViewUrl(serverUrl, pkg) {
135
+ const cleanServerUrl = normalizePortalUrl(serverUrl);
136
+ const portal = getPortalConfig(serverUrl);
114
137
  const template = portal?.dataset_view_url || portals_default.defaults.dataset_view_url;
115
138
  return template.replace("{server_url}", cleanServerUrl).replace("{id}", pkg.id).replace("{name}", pkg.name);
116
139
  }
117
140
  function getOrganizationViewUrl(serverUrl, org) {
118
- const cleanServerUrl = normalizeUrl(serverUrl);
119
- const portal = portals_default.portals.find((p) => {
120
- const mainUrl = normalizeUrl(p.api_url);
121
- const aliases = (p.api_url_aliases || []).map(normalizeUrl);
122
- return mainUrl === cleanServerUrl || aliases.includes(cleanServerUrl);
123
- });
141
+ const cleanServerUrl = normalizePortalUrl(serverUrl);
142
+ const portal = getPortalConfig(serverUrl);
124
143
  const template = portal?.organization_view_url || portals_default.defaults.organization_view_url;
125
144
  return template.replace("{server_url}", cleanServerUrl).replace("{id}", org.id).replace("{name}", org.name);
126
145
  }
127
146
 
147
+ // src/utils/search.ts
148
+ var DEFAULT_SEARCH_QUERY = "*:*";
149
+ var FIELD_QUERY_PATTERN = /\b[a-zA-Z_][\w-]*:/;
150
+ function isFieldedQuery(query) {
151
+ return FIELD_QUERY_PATTERN.test(query);
152
+ }
153
+ function resolveSearchQuery(serverUrl, query, parserOverride) {
154
+ const portalSearchConfig = getPortalSearchConfig(serverUrl);
155
+ const portalForce = portalSearchConfig.force_text_field ?? false;
156
+ let forceTextField = false;
157
+ if (parserOverride === "text") {
158
+ forceTextField = true;
159
+ } else if (parserOverride === "default") {
160
+ forceTextField = false;
161
+ } else if (portalForce) {
162
+ const trimmedQuery = query.trim();
163
+ forceTextField = trimmedQuery !== DEFAULT_SEARCH_QUERY && !isFieldedQuery(trimmedQuery);
164
+ }
165
+ const effectiveQuery = forceTextField ? `text:(${query})` : query;
166
+ return { effectiveQuery, forcedTextField: forceTextField };
167
+ }
168
+
128
169
  // src/tools/package.ts
170
+ var DEFAULT_RELEVANCE_WEIGHTS = {
171
+ title: 4,
172
+ notes: 2,
173
+ tags: 3,
174
+ organization: 1
175
+ };
176
+ var QUERY_STOPWORDS = /* @__PURE__ */ new Set([
177
+ "a",
178
+ "an",
179
+ "the",
180
+ "and",
181
+ "or",
182
+ "but",
183
+ "in",
184
+ "on",
185
+ "at",
186
+ "to",
187
+ "for",
188
+ "of",
189
+ "with",
190
+ "by",
191
+ "from",
192
+ "as",
193
+ "is",
194
+ "was",
195
+ "are",
196
+ "were",
197
+ "be",
198
+ "been",
199
+ "being",
200
+ "have",
201
+ "has",
202
+ "had",
203
+ "do",
204
+ "does",
205
+ "did",
206
+ "will",
207
+ "would",
208
+ "could",
209
+ "should",
210
+ "may",
211
+ "might",
212
+ "must",
213
+ "can",
214
+ "this",
215
+ "that",
216
+ "these",
217
+ "those"
218
+ ]);
219
+ var extractQueryTerms = (query) => {
220
+ const matches = query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
221
+ const terms = matches.filter((term) => term.length > 1 && !QUERY_STOPWORDS.has(term));
222
+ return Array.from(new Set(terms));
223
+ };
224
+ var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
225
+ var textMatchesTerms = (text, terms) => {
226
+ if (!text || terms.length === 0) return false;
227
+ const normalized = text.toLowerCase().replace(/_/g, " ");
228
+ return terms.some((term) => new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(normalized));
229
+ };
230
+ var scoreTextField = (text, terms, weight) => {
231
+ return textMatchesTerms(text, terms) ? weight : 0;
232
+ };
233
+ var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS) => {
234
+ const terms = extractQueryTerms(query);
235
+ const titleText = dataset.title || dataset.name || "";
236
+ const notesText = dataset.notes || "";
237
+ const orgText = dataset.organization?.title || dataset.organization?.name || dataset.owner_org || "";
238
+ const breakdown = {
239
+ title: scoreTextField(titleText, terms, weights.title),
240
+ notes: scoreTextField(notesText, terms, weights.notes),
241
+ tags: 0,
242
+ organization: scoreTextField(orgText, terms, weights.organization),
243
+ total: 0
244
+ };
245
+ if (Array.isArray(dataset.tags) && dataset.tags.length > 0 && terms.length > 0) {
246
+ const tagMatch = dataset.tags.some((tag) => {
247
+ const tagValue = typeof tag === "string" ? tag : tag?.name;
248
+ return textMatchesTerms(tagValue, terms);
249
+ });
250
+ breakdown.tags = tagMatch ? weights.tags : 0;
251
+ }
252
+ breakdown.total = breakdown.title + breakdown.notes + breakdown.tags + breakdown.organization;
253
+ return { total: breakdown.total, breakdown, terms };
254
+ };
129
255
  function registerPackageTools(server2) {
130
256
  server2.registerTool(
131
257
  "ckan_package_search",
@@ -136,8 +262,13 @@ function registerPackageTools(server2) {
136
262
  Supports full Solr search capabilities including filters, facets, and sorting.
137
263
  Use this to discover datasets matching specific criteria.
138
264
 
265
+ Note on parser behavior:
266
+ Some CKAN portals use a restrictive default query parser that can break long OR queries.
267
+ For those portals, this tool may force the query into 'text:(...)' based on per-portal config.
268
+ You can override with 'query_parser' to force or disable this behavior per request.
269
+
139
270
  Args:
140
- - server_url (string): Base URL of CKAN server (e.g., "https://dati.gov.it")
271
+ - server_url (string): Base URL of CKAN server (e.g., "https://dati.gov.it/opendata")
141
272
  - q (string): Search query using Solr syntax (default: "*:*" for all)
142
273
  - fq (string): Filter query (e.g., "organization:comune-palermo")
143
274
  - rows (number): Number of results to return (default: 10, max: 1000)
@@ -146,6 +277,7 @@ Args:
146
277
  - facet_field (array): Fields to facet on (e.g., ["organization", "tags"])
147
278
  - facet_limit (number): Max facet values per field (default: 50)
148
279
  - include_drafts (boolean): Include draft datasets (default: false)
280
+ - query_parser ('default' | 'text'): Override search parser behavior
149
281
  - response_format ('markdown' | 'json'): Output format
150
282
 
151
283
  Returns:
@@ -215,6 +347,7 @@ Examples:
215
347
  facet_field: z2.array(z2.string()).optional().describe("Fields to facet on"),
216
348
  facet_limit: z2.number().int().min(1).optional().default(50).describe("Maximum facet values per field"),
217
349
  include_drafts: z2.boolean().optional().default(false).describe("Include draft datasets"),
350
+ query_parser: z2.enum(["default", "text"]).optional().describe("Override search parser ('text' forces text:(...) on non-fielded queries)"),
218
351
  response_format: ResponseFormatSchema
219
352
  }).strict(),
220
353
  annotations: {
@@ -226,8 +359,13 @@ Examples:
226
359
  },
227
360
  async (params) => {
228
361
  try {
362
+ const { effectiveQuery } = resolveSearchQuery(
363
+ params.server_url,
364
+ params.q,
365
+ params.query_parser
366
+ );
229
367
  const apiParams = {
230
- q: params.q,
368
+ q: effectiveQuery,
231
369
  rows: params.rows,
232
370
  start: params.start,
233
371
  include_private: params.include_drafts
@@ -253,6 +391,8 @@ Examples:
253
391
 
254
392
  **Server**: ${params.server_url}
255
393
  **Query**: ${params.q}
394
+ ${effectiveQuery !== params.q ? `**Effective Query**: ${effectiveQuery}
395
+ ` : ""}
256
396
  ${params.fq ? `**Filter**: ${params.fq}
257
397
  ` : ""}
258
398
  **Total Results**: ${result.count}
@@ -335,6 +475,184 @@ ${params.fq ? `**Filter**: ${params.fq}
335
475
  }
336
476
  }
337
477
  );
478
+ server2.registerTool(
479
+ "ckan_find_relevant_datasets",
480
+ {
481
+ title: "Find Relevant CKAN Datasets",
482
+ description: `Find and rank datasets by relevance to a query using weighted fields.
483
+
484
+ Uses package_search for discovery and applies a local scoring model.
485
+
486
+ Args:
487
+ - server_url (string): Base URL of CKAN server (e.g., "https://dati.gov.it/opendata")
488
+ - query (string): Search query text
489
+ - limit (number): Number of datasets to return (default: 10)
490
+ - weights (object): Optional weights for title/notes/tags/organization
491
+ - query_parser ('default' | 'text'): Override search parser behavior
492
+ - response_format ('markdown' | 'json'): Output format
493
+
494
+ Returns:
495
+ Ranked datasets with relevance scores and breakdowns
496
+
497
+ Examples:
498
+ - { server_url: "https://dati.gov.it/opendata", query: "mobilit\xE0" }
499
+ - { server_url: "...", query: "trasporti", limit: 5, weights: { title: 5, notes: 2 } }`,
500
+ inputSchema: z2.object({
501
+ server_url: z2.string().url().describe("Base URL of the CKAN server (e.g., https://dati.gov.it/opendata)"),
502
+ query: z2.string().min(2).describe("Search query text"),
503
+ limit: z2.number().int().min(1).max(50).optional().default(10).describe("Number of datasets to return"),
504
+ weights: z2.object({
505
+ title: z2.number().min(0).optional(),
506
+ notes: z2.number().min(0).optional(),
507
+ tags: z2.number().min(0).optional(),
508
+ organization: z2.number().min(0).optional()
509
+ }).optional().describe("Optional weights per field"),
510
+ query_parser: z2.enum(["default", "text"]).optional().describe("Override search parser ('text' forces text:(...) on non-fielded queries)"),
511
+ response_format: ResponseFormatSchema
512
+ }).strict(),
513
+ annotations: {
514
+ readOnlyHint: true,
515
+ destructiveHint: false,
516
+ idempotentHint: true,
517
+ openWorldHint: true
518
+ }
519
+ },
520
+ async (params) => {
521
+ try {
522
+ const weights = {
523
+ ...DEFAULT_RELEVANCE_WEIGHTS,
524
+ ...params.weights ?? {}
525
+ };
526
+ const rows = Math.min(Math.max(params.limit * 5, params.limit), 100);
527
+ const { effectiveQuery } = resolveSearchQuery(
528
+ params.server_url,
529
+ params.query,
530
+ params.query_parser
531
+ );
532
+ const searchResult = await makeCkanRequest(
533
+ params.server_url,
534
+ "package_search",
535
+ {
536
+ q: effectiveQuery,
537
+ rows,
538
+ start: 0
539
+ }
540
+ );
541
+ const scored = (searchResult.results || []).map((dataset) => {
542
+ const { total, breakdown } = scoreDatasetRelevance(
543
+ params.query,
544
+ dataset,
545
+ weights
546
+ );
547
+ return {
548
+ dataset,
549
+ score: total,
550
+ breakdown
551
+ };
552
+ });
553
+ scored.sort((a, b) => b.score - a.score);
554
+ const top = scored.slice(0, params.limit).map((item) => {
555
+ const dataset = item.dataset;
556
+ return {
557
+ id: dataset.id,
558
+ name: dataset.name,
559
+ title: dataset.title || dataset.name,
560
+ organization: dataset.organization?.title || dataset.organization?.name || dataset.owner_org,
561
+ tags: Array.isArray(dataset.tags) ? dataset.tags.map((tag) => tag.name) : [],
562
+ metadata_modified: dataset.metadata_modified,
563
+ score: item.score,
564
+ breakdown: item.breakdown
565
+ };
566
+ });
567
+ const payload = {
568
+ query: params.query,
569
+ terms: extractQueryTerms(params.query),
570
+ weights,
571
+ total_results: searchResult.count ?? 0,
572
+ returned: top.length,
573
+ results: top
574
+ };
575
+ if (params.response_format === "json" /* JSON */) {
576
+ return {
577
+ content: [{ type: "text", text: truncateText(JSON.stringify(payload, null, 2)) }],
578
+ structuredContent: payload
579
+ };
580
+ }
581
+ let markdown = `# Relevant CKAN Datasets
582
+
583
+ `;
584
+ markdown += `**Server**: ${params.server_url}
585
+ `;
586
+ markdown += `**Query**: ${params.query}
587
+ `;
588
+ markdown += `**Terms**: ${payload.terms.length > 0 ? payload.terms.join(", ") : "n/a"}
589
+ `;
590
+ markdown += `**Total Results**: ${payload.total_results}
591
+ `;
592
+ markdown += `**Returned**: ${payload.returned}
593
+
594
+ `;
595
+ markdown += `## Weights
596
+
597
+ `;
598
+ markdown += `- **Title**: ${weights.title}
599
+ `;
600
+ markdown += `- **Notes**: ${weights.notes}
601
+ `;
602
+ markdown += `- **Tags**: ${weights.tags}
603
+ `;
604
+ markdown += `- **Organization**: ${weights.organization}
605
+
606
+ `;
607
+ if (top.length === 0) {
608
+ markdown += "No datasets matched the query terms.\n";
609
+ } else {
610
+ markdown += `## Results
611
+
612
+ `;
613
+ markdown += `| Rank | Dataset | Score | Title | Org | Tags |
614
+ `;
615
+ markdown += `| --- | --- | --- | --- | --- | --- |
616
+ `;
617
+ top.forEach((dataset, index) => {
618
+ const tags = dataset.tags.slice(0, 3).join(", ");
619
+ markdown += `| ${index + 1} | ${dataset.name} | ${dataset.score} | ${dataset.title} | ${dataset.organization || "-"} | ${tags || "-"} |
620
+ `;
621
+ });
622
+ markdown += `
623
+ ### Score Breakdown
624
+
625
+ `;
626
+ top.forEach((dataset, index) => {
627
+ markdown += `**${index + 1}. ${dataset.title}**
628
+ `;
629
+ markdown += `- Title: ${dataset.breakdown.title}
630
+ `;
631
+ markdown += `- Notes: ${dataset.breakdown.notes}
632
+ `;
633
+ markdown += `- Tags: ${dataset.breakdown.tags}
634
+ `;
635
+ markdown += `- Organization: ${dataset.breakdown.organization}
636
+ `;
637
+ markdown += `- Total: ${dataset.breakdown.total}
638
+
639
+ `;
640
+ });
641
+ }
642
+ return {
643
+ content: [{ type: "text", text: truncateText(markdown) }]
644
+ };
645
+ } catch (error) {
646
+ return {
647
+ content: [{
648
+ type: "text",
649
+ text: `Error ranking datasets: ${error instanceof Error ? error.message : String(error)}`
650
+ }],
651
+ isError: true
652
+ };
653
+ }
654
+ }
655
+ );
338
656
  server2.registerTool(
339
657
  "ckan_package_show",
340
658
  {
@@ -353,7 +671,7 @@ Returns:
353
671
  Complete dataset object with all metadata and resources
354
672
 
355
673
  Examples:
356
- - { server_url: "https://dati.gov.it", id: "dataset-name" }
674
+ - { server_url: "https://dati.gov.it/opendata", id: "dataset-name" }
357
675
  - { server_url: "...", id: "abc-123-def", include_tracking: true }`,
358
676
  inputSchema: z2.object({
359
677
  server_url: z2.string().url().describe("Base URL of the CKAN server"),
@@ -998,6 +1316,110 @@ Examples:
998
1316
  }
999
1317
  }
1000
1318
  );
1319
+ server2.registerTool(
1320
+ "ckan_datastore_search_sql",
1321
+ {
1322
+ title: "Search CKAN DataStore with SQL",
1323
+ description: `Run SQL queries on a CKAN DataStore resource.
1324
+
1325
+ This endpoint is only available on CKAN portals with DataStore enabled and SQL access exposed.
1326
+
1327
+ Args:
1328
+ - server_url (string): Base URL of CKAN server
1329
+ - sql (string): SQL query (e.g., SELECT * FROM "resource_id" LIMIT 10)
1330
+ - response_format ('markdown' | 'json'): Output format
1331
+
1332
+ Returns:
1333
+ SQL query results from DataStore
1334
+
1335
+ Examples:
1336
+ - { server_url: "...", sql: "SELECT * FROM "abc-123" LIMIT 10" }
1337
+ - { server_url: "...", sql: "SELECT COUNT(*) AS total FROM "abc-123"" }`,
1338
+ inputSchema: z4.object({
1339
+ server_url: z4.string().url(),
1340
+ sql: z4.string().min(1),
1341
+ response_format: ResponseFormatSchema
1342
+ }).strict(),
1343
+ annotations: {
1344
+ readOnlyHint: true,
1345
+ destructiveHint: false,
1346
+ idempotentHint: true,
1347
+ openWorldHint: false
1348
+ }
1349
+ },
1350
+ async (params) => {
1351
+ try {
1352
+ const result = await makeCkanRequest(
1353
+ params.server_url,
1354
+ "datastore_search_sql",
1355
+ { sql: params.sql }
1356
+ );
1357
+ if (params.response_format === "json" /* JSON */) {
1358
+ return {
1359
+ content: [{ type: "text", text: truncateText(JSON.stringify(result, null, 2)) }],
1360
+ structuredContent: result
1361
+ };
1362
+ }
1363
+ const records = result.records || [];
1364
+ const fieldIds = result.fields?.map((field) => field.id) || Object.keys(records[0] || {});
1365
+ let markdown = `# DataStore SQL Results
1366
+
1367
+ `;
1368
+ markdown += `**Server**: ${params.server_url}
1369
+ `;
1370
+ markdown += `**SQL**: \`${params.sql}\`
1371
+ `;
1372
+ markdown += `**Returned**: ${records.length} records
1373
+
1374
+ `;
1375
+ if (result.fields && result.fields.length > 0) {
1376
+ markdown += `## Fields
1377
+
1378
+ `;
1379
+ markdown += result.fields.map((field) => `- **${field.id}** (${field.type})`).join("\n") + "\n\n";
1380
+ }
1381
+ if (records.length > 0 && fieldIds.length > 0) {
1382
+ markdown += `## Records
1383
+
1384
+ `;
1385
+ const displayFields = fieldIds.slice(0, 8);
1386
+ markdown += `| ${displayFields.join(" | ")} |
1387
+ `;
1388
+ markdown += `| ${displayFields.map(() => "---").join(" | ")} |
1389
+ `;
1390
+ for (const record of records.slice(0, 50)) {
1391
+ const values = displayFields.map((field) => {
1392
+ const value = record[field];
1393
+ if (value === null || value === void 0) return "-";
1394
+ const text = String(value);
1395
+ return text.length > 50 ? text.substring(0, 47) + "..." : text;
1396
+ });
1397
+ markdown += `| ${values.join(" | ")} |
1398
+ `;
1399
+ }
1400
+ if (records.length > 50) {
1401
+ markdown += `
1402
+ ... and ${records.length - 50} more records
1403
+ `;
1404
+ }
1405
+ markdown += "\n";
1406
+ } else {
1407
+ markdown += "No records returned by the SQL query.\n";
1408
+ }
1409
+ return {
1410
+ content: [{ type: "text", text: truncateText(markdown) }]
1411
+ };
1412
+ } catch (error) {
1413
+ return {
1414
+ content: [{
1415
+ type: "text",
1416
+ text: `Error querying DataStore SQL: ${error instanceof Error ? error.message : String(error)}`
1417
+ }],
1418
+ isError: true
1419
+ };
1420
+ }
1421
+ }
1422
+ );
1001
1423
  }
1002
1424
 
1003
1425
  // src/tools/status.ts
@@ -1727,7 +2149,7 @@ function registerAllResources(server2) {
1727
2149
  function createServer() {
1728
2150
  return new McpServer({
1729
2151
  name: "ckan-mcp-server",
1730
- version: "0.4.3"
2152
+ version: "0.4.7"
1731
2153
  });
1732
2154
  }
1733
2155
  function registerAll(server2) {