@aborruso/ckan-mcp-server 0.4.49 → 0.4.50

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/AGENTS.md CHANGED
@@ -118,6 +118,43 @@ Node `>=18`. Worker build in `wrangler.toml`. Vitest coverage thresholds enforce
118
118
 
119
119
  Minimal focused diffs. No unrelated refactors. Update tests for behavior changes. Avoid editing `dist/`.
120
120
 
121
+ ## Pre-commit Checklist
122
+
123
+ Before committing and pushing any locally testable change:
124
+ 1. Build: `npm run build`
125
+ 2. Automated tests: `npm test` — all must pass
126
+ 3. Manual queries: run real requests against the built server to verify end-to-end behavior
127
+
128
+ ### How to run manual queries
129
+
130
+ ```bash
131
+ # Terminal 1 — start server
132
+ TRANSPORT=http PORT=3001 node dist/index.js
133
+
134
+ # Terminal 2 — call a tool
135
+ curl -s -X POST http://localhost:3001/mcp \
136
+ -H "Content-Type: application/json" \
137
+ -H "Accept: application/json, text/event-stream" \
138
+ -d '{
139
+ "jsonrpc":"2.0",
140
+ "method":"tools/call",
141
+ "params":{
142
+ "name":"ckan_package_search",
143
+ "arguments":{
144
+ "server_url":"https://www.dati.gov.it/opendata",
145
+ "q":"ambiente",
146
+ "page":1,
147
+ "page_size":3
148
+ }
149
+ },
150
+ "id":1
151
+ }'
152
+ ```
153
+
154
+ - Always include both `Content-Type: application/json` and `Accept: application/json, text/event-stream`
155
+ - Use `node dist/index.js` directly, not `npm start`
156
+ - Use port 3001 to avoid conflicts
157
+
121
158
  ## Project Layout
122
159
 
123
160
  `src/index.ts` entry, `src/server.ts` wiring, `src/tools/` handlers, `src/utils/` helpers, `src/resources/` templates, `src/transport/` stdio/HTTP. `tests/unit/` utilities, `tests/integration/` behavior, `tests/fixtures/` mocks.
package/LOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # LOG
2
2
 
3
+ ## 2026-02-26 (v0.4.50)
4
+
5
+ - `ckan_list_resources`: add `format_filter` param (case-insensitive, client-side) — e.g. 72 resources → 8 CSV; header shows "Total: 72 (showing 8 CSV)"
6
+ - `ckan_package_search`: OR tip on zero results — when a plain multi-term query returns 0, suggest the OR version (e.g. `"a b c"` → `"a OR b OR c"`)
7
+ - `ckan_package_search`: accent fallback — if query returns 0 results and contains accented chars, retry with accent-stripped query; note shown in output
8
+ - `ckan_package_show`: always show DataStore status per resource
9
+ - `✅ Available` / `❌ Not available` / `❓ Not reported by portal`
10
+ - Previously silent when field absent (e.g. dati.gov.it); now explicit
11
+
3
12
  ## 2026-02-25 (v0.4.49)
4
13
 
5
14
  - Disable DataStore Table UI component (MCP Apps) pending use-case design
package/README.md CHANGED
@@ -18,7 +18,7 @@ MCP (Model Context Protocol) server for interacting with CKAN-based open data po
18
18
  - 📄 MCP Resource Templates for direct data access
19
19
  - 🧭 Guided MCP prompts for common workflows
20
20
  - 🛡️ Browser-like headers to avoid WAF blocks
21
- - 🧪 Test suite with 214 tests (100% passing)
21
+ - 🧪 Comprehensive test suite (100% passing)
22
22
 
23
23
  👉 If you want to dive deeper, the [**AI-generated DeepWiki**](https://deepwiki.com/ondata/ckan-mcp-server) is very well done.
24
24
 
@@ -188,6 +188,25 @@ npm install -g @aborruso/ckan-mcp-server
188
188
 
189
189
  ⚠️ **Warning**: Demo instance with 100,000 requests/month shared globally across all users. Not reliable for production use.
190
190
 
191
+ **Claude Desktop on Windows reading from a local MCP server installed on WSL2**:
192
+
193
+ ```json
194
+ {
195
+ "mcpServers": {
196
+ "ckan": {
197
+ "command": "wsl.exe",
198
+ "args": [
199
+ "-e",
200
+ "/usr/local/bin/node",
201
+ "/home/username/projects/ckan-mcp-server/dist/index.js"
202
+ ]
203
+ }
204
+ }
205
+ }
206
+ ```
207
+
208
+ This requires the server to be built (`npm run build`) inside the WSL2 environment before use.
209
+
191
210
  ### Web Tools
192
211
 
193
212
  #### ChatGPT
package/dist/index.js CHANGED
@@ -489,6 +489,24 @@ function convertDateMathForUnsupportedFields(query) {
489
489
  return `${field}:[${startIso} TO ${nowIso}]`;
490
490
  });
491
491
  }
492
+ var EXPLICIT_BOOL_PATTERN = /\b(AND|OR|NOT)\b|[+\-!]/;
493
+ function isPlainMultiTermQuery(query) {
494
+ const trimmed = query.trim();
495
+ if (trimmed === "*:*" || trimmed === "") return false;
496
+ if (FIELD_QUERY_PATTERN.test(trimmed)) return false;
497
+ if (EXPLICIT_BOOL_PATTERN.test(trimmed)) return false;
498
+ const words = trimmed.split(/\s+/).filter(Boolean);
499
+ return words.length > 1;
500
+ }
501
+ function buildOrQuery(query) {
502
+ return query.trim().split(/\s+/).filter(Boolean).join(" OR ");
503
+ }
504
+ function stripAccents(text) {
505
+ return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
506
+ }
507
+ function hasAccents(text) {
508
+ return text !== stripAccents(text);
509
+ }
492
510
  function resolveSearchQuery(serverUrl, query, parserOverride) {
493
511
  const portalSearchConfig = getPortalSearchConfig(serverUrl);
494
512
  const portalForce = portalSearchConfig.force_text_field ?? false;
@@ -761,8 +779,14 @@ ${result.notes}
761
779
  `;
762
780
  if (resource.last_modified) markdown += `- **Modified**: ${formatDate(resource.last_modified)}
763
781
  `;
764
- if (resource.datastore_active !== void 0) {
765
- markdown += `- **DataStore**: ${resource.datastore_active ? "\u2705 Available" : "\u274C Not available"}
782
+ if (resource.datastore_active === true) {
783
+ markdown += `- **DataStore**: \u2705 Available
784
+ `;
785
+ } else if (resource.datastore_active === false) {
786
+ markdown += `- **DataStore**: \u274C Not available
787
+ `;
788
+ } else {
789
+ markdown += `- **DataStore**: \u2753 Not reported by portal
766
790
  `;
767
791
  }
768
792
  markdown += "\n";
@@ -780,6 +804,12 @@ ${result.notes}
780
804
  }
781
805
  return markdown;
782
806
  };
807
+ function resolvePageParams(page, pageSize, start, rows) {
808
+ if (page !== void 0) {
809
+ return { effectiveStart: (page - 1) * pageSize, effectiveRows: pageSize };
810
+ }
811
+ return { effectiveStart: start, effectiveRows: rows };
812
+ }
783
813
  function registerPackageTools(server2) {
784
814
  server2.registerTool(
785
815
  "ckan_package_search",
@@ -819,6 +849,8 @@ Args:
819
849
  - fq (string): Filter query (e.g., "organization:comune-palermo")
820
850
  - rows (number): Number of results to return (default: 10, max: 1000)
821
851
  - start (number): Offset for pagination (default: 0)
852
+ - page (number): Page number (1-based); alias for start. Overrides start if provided.
853
+ - page_size (number): Results per page when using page (default: 10, max: 1000)
822
854
  - sort (string): Sort field and direction (e.g., "metadata_modified desc")
823
855
  - facet_field (array): Fields to facet on (e.g., ["organization", "tags"])
824
856
  - facet_limit (number): Max facet values per field (default: 50)
@@ -899,6 +931,8 @@ Typical workflow: ckan_package_search \u2192 ckan_package_show (get full metadat
899
931
  sort: z2.string().optional().describe("Sort field and direction (e.g., 'metadata_modified desc')"),
900
932
  facet_field: z2.array(z2.string()).optional().describe("Fields to facet on"),
901
933
  facet_limit: z2.number().int().min(1).optional().default(50).describe("Maximum facet values per field"),
934
+ page: z2.number().int().min(1).optional().describe("Page number (1-based); alias for start. Overrides start if provided."),
935
+ page_size: z2.number().int().min(1).max(1e3).optional().default(10).describe("Results per page when using page (default: 10)"),
902
936
  include_drafts: z2.boolean().optional().default(false).describe("Include draft datasets"),
903
937
  content_recent: z2.boolean().optional().default(false).describe("Use issued date with fallback to metadata_created for recent content"),
904
938
  content_recent_days: z2.number().int().min(1).optional().default(30).describe("Day window for content_recent (default 30)"),
@@ -928,10 +962,11 @@ Typical workflow: ckan_package_search \u2192 ckan_package_show (get full metadat
928
962
  query,
929
963
  params.query_parser
930
964
  );
965
+ const { effectiveRows, effectiveStart } = resolvePageParams(params.page, params.page_size, params.start, params.rows);
931
966
  const apiParams = {
932
967
  q: effectiveQuery,
933
- rows: params.rows,
934
- start: params.start,
968
+ rows: effectiveRows,
969
+ start: effectiveStart,
935
970
  include_private: params.include_drafts
936
971
  };
937
972
  if (params.fq) apiParams.fq = params.fq;
@@ -940,11 +975,29 @@ Typical workflow: ckan_package_search \u2192 ckan_package_show (get full metadat
940
975
  apiParams["facet.field"] = JSON.stringify(params.facet_field);
941
976
  apiParams["facet.limit"] = params.facet_limit;
942
977
  }
943
- const result = await makeCkanRequest(
978
+ let result = await makeCkanRequest(
944
979
  params.server_url,
945
980
  "package_search",
946
981
  apiParams
947
982
  );
983
+ let accentFallbackUsed = false;
984
+ if (result.count === 0 && hasAccents(params.q)) {
985
+ const strippedQuery = stripAccents(params.q);
986
+ const { effectiveQuery: strippedEffective } = resolveSearchQuery(
987
+ params.server_url,
988
+ strippedQuery,
989
+ params.query_parser
990
+ );
991
+ const fallbackResult = await makeCkanRequest(
992
+ params.server_url,
993
+ "package_search",
994
+ { ...apiParams, q: strippedEffective }
995
+ );
996
+ if (fallbackResult.count > 0) {
997
+ result = fallbackResult;
998
+ accentFallbackUsed = true;
999
+ }
1000
+ }
948
1001
  if (params.response_format === "json" /* JSON */) {
949
1002
  return {
950
1003
  content: [{ type: "text", text: truncateText(JSON.stringify(result, null, 2)) }]
@@ -958,10 +1011,12 @@ ${params.content_recent ? `**Content Recent**: last ${params.content_recent_days
958
1011
  ` : ""}
959
1012
  ${effectiveQuery !== userQuery ? `**Effective Query**: ${effectiveQuery}
960
1013
  ` : ""}
1014
+ ${accentFallbackUsed ? `**Note**: Original query returned 0 results; retried with accent-stripped query "${stripAccents(params.q)}".
1015
+ ` : ""}
961
1016
  ${params.fq ? `**Filter**: ${params.fq}
962
1017
  ` : ""}
963
1018
  **Total Results**: ${result.count}
964
- **Showing**: ${result.results.length} results (from ${params.start})
1019
+ **Showing**: ${result.results.length} results (from ${effectiveStart})
965
1020
 
966
1021
  `;
967
1022
  if (result.facets && Object.keys(result.facets).length > 0) {
@@ -1027,13 +1082,27 @@ Note: showing top ${sorted.length} only. Use \`response_format: json\` for full
1027
1082
  } else {
1028
1083
  markdown += `No datasets found matching your query.
1029
1084
  `;
1085
+ if (isPlainMultiTermQuery(params.q)) {
1086
+ markdown += `
1087
+ > **Tip**: Multi-term queries use AND by default (all terms must match). Try OR to broaden the search:
1088
+ `;
1089
+ markdown += `> \`q: "${buildOrQuery(params.q)}"\`
1090
+ `;
1091
+ }
1030
1092
  }
1031
- if (result.count > params.start + params.rows) {
1032
- const nextStart = params.start + params.rows;
1033
- markdown += `
1093
+ if (result.count > effectiveStart + effectiveRows) {
1094
+ if (params.page !== void 0) {
1095
+ markdown += `
1096
+ ---
1097
+ **More results available**: Use \`page: ${params.page + 1}\` to see next page.
1098
+ `;
1099
+ } else {
1100
+ const nextStart = effectiveStart + effectiveRows;
1101
+ markdown += `
1034
1102
  ---
1035
1103
  **More results available**: Use \`start: ${nextStart}\` to see next page.
1036
1104
  `;
1105
+ }
1037
1106
  }
1038
1107
  return {
1039
1108
  content: [{ type: "text", text: truncateText(addDemoFooter(markdown)) }]
@@ -1319,6 +1388,7 @@ Use this to quickly assess what files a dataset contains before deciding how to
1319
1388
  Args:
1320
1389
  - server_url (string): Base URL of CKAN server
1321
1390
  - id (string): Dataset ID or name
1391
+ - format_filter (string): Filter resources by format, case-insensitive (e.g., "CSV", "json", "XLSX")
1322
1392
  - response_format ('markdown' | 'json'): Output format
1323
1393
 
1324
1394
  Returns:
@@ -1326,11 +1396,13 @@ Returns:
1326
1396
 
1327
1397
  Examples:
1328
1398
  - { server_url: "https://dati.gov.it/opendata", id: "dataset-name" }
1399
+ - { server_url: "...", id: "dataset-name", format_filter: "CSV" }
1329
1400
 
1330
1401
  Typical workflow: ckan_package_search \u2192 ckan_list_resources (assess available files) \u2192 ckan_datastore_search (for resources with DataStore=true)`,
1331
1402
  inputSchema: z2.object({
1332
1403
  server_url: z2.string().url().describe("Base URL of the CKAN server"),
1333
1404
  id: z2.string().min(1).describe("Dataset ID or name"),
1405
+ format_filter: z2.string().optional().describe("Filter resources by format, case-insensitive (e.g., 'CSV', 'json', 'XLSX')"),
1334
1406
  response_format: ResponseFormatSchema
1335
1407
  }).strict(),
1336
1408
  annotations: {
@@ -1348,7 +1420,8 @@ Typical workflow: ckan_package_search \u2192 ckan_list_resources (assess availab
1348
1420
  { id: params.id }
1349
1421
  );
1350
1422
  const resources = Array.isArray(result.resources) ? result.resources : [];
1351
- const summary = resources.map((r) => {
1423
+ const formatFilter = params.format_filter?.toUpperCase();
1424
+ const summary = resources.filter((r) => !formatFilter || (r.format || "").toUpperCase() === formatFilter).map((r) => {
1352
1425
  const effectiveUrl = resolveDownloadUrl(r);
1353
1426
  return {
1354
1427
  name: r.name || "Unnamed Resource",
@@ -1364,7 +1437,9 @@ Typical workflow: ckan_package_search \u2192 ckan_list_resources (assess availab
1364
1437
  dataset_id: result.id,
1365
1438
  dataset_name: result.name,
1366
1439
  dataset_title: result.title || result.name,
1367
- total_resources: summary.length,
1440
+ total_resources: resources.length,
1441
+ filtered_resources: summary.length,
1442
+ format_filter: formatFilter ?? null,
1368
1443
  resources: summary
1369
1444
  };
1370
1445
  return {
@@ -1379,7 +1454,11 @@ Typical workflow: ckan_package_search \u2192 ckan_list_resources (assess availab
1379
1454
  `;
1380
1455
  markdown += `**Dataset**: \`${result.name}\` (\`${result.id}\`)
1381
1456
  `;
1382
- markdown += `**Total Resources**: ${summary.length}
1457
+ markdown += `**Total Resources**: ${resources.length}`;
1458
+ if (formatFilter) {
1459
+ markdown += ` (showing ${summary.length} ${formatFilter})`;
1460
+ }
1461
+ markdown += `
1383
1462
 
1384
1463
  `;
1385
1464
  if (summary.length === 0) {
@@ -3896,7 +3975,7 @@ var registerAllPrompts = (server2) => {
3896
3975
  function createServer() {
3897
3976
  return new McpServer({
3898
3977
  name: "ckan-mcp-server",
3899
- version: "0.4.49"
3978
+ version: "0.4.50"
3900
3979
  });
3901
3980
  }
3902
3981
  function registerAll(server2) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.49",
3
+ "version": "0.4.50",
4
4
  "description": "MCP server for interacting with CKAN open data portals",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",