@aborruso/ckan-mcp-server 0.4.6 → 0.4.8

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/README.md CHANGED
@@ -40,27 +40,11 @@ npm run build
40
40
  npm test
41
41
  ```
42
42
 
43
- ## Usage
44
-
45
- ### Start with stdio (for local integration)
46
-
47
- ```bash
48
- npm start
49
- ```
50
-
51
- ### Start with HTTP (for remote access)
52
-
53
- ```bash
54
- TRANSPORT=http PORT=3000 npm start
55
- ```
56
-
57
- The server will be available at `http://localhost:3000/mcp`
58
-
59
43
  ## Usage Options
60
44
 
61
45
  ### Option 1: Local Installation (stdio mode)
62
46
 
63
- **Best for**: Personal use with Claude Desktop
47
+ **Best for**: Personal use with local MCP clients
64
48
 
65
49
  Install and run locally on your machine (see Installation section above).
66
50
 
@@ -90,9 +74,11 @@ Use the public Workers endpoint (no local install required):
90
74
  }
91
75
  ```
92
76
 
77
+ **NOTE**: This service uses the Cloudflare Workers free tier which has a limit of 100,000 requests per month.
78
+
93
79
  Want your own deployment? See [DEPLOYMENT.md](docs/DEPLOYMENT.md).
94
80
 
95
- ## Claude Desktop Configuration
81
+ ### Claude Desktop Configuration
96
82
 
97
83
  Configuration file location:
98
84
 
@@ -100,7 +86,7 @@ Configuration file location:
100
86
  - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
101
87
  - **Linux**: `~/.config/Claude/claude_desktop_config.json`
102
88
 
103
- ### Option 1: Global Installation (Recommended)
89
+ #### Option 1: Global Installation (Recommended)
104
90
 
105
91
  Install globally to use across all projects:
106
92
 
@@ -120,7 +106,7 @@ Then add to `claude_desktop_config.json`:
120
106
  }
121
107
  ```
122
108
 
123
- ### Option 2: Local Installation (Optional)
109
+ #### Option 2: Local Installation (Optional)
124
110
 
125
111
  If you installed locally (see Installation), use this config:
126
112
 
@@ -137,7 +123,7 @@ If you installed locally (see Installation), use this config:
137
123
 
138
124
  Replace `/absolute/path/to/project` with your actual project path.
139
125
 
140
- ### Option 3: From Source
126
+ #### Option 3: From Source
141
127
 
142
128
  If you cloned the repository:
143
129
 
@@ -152,7 +138,7 @@ If you cloned the repository:
152
138
  }
153
139
  ```
154
140
 
155
- ### Option 4: Cloudflare Workers (HTTP transport)
141
+ #### Option 4: Cloudflare Workers (HTTP transport)
156
142
 
157
143
  Use the public Cloudflare Workers deployment (no local installation required):
158
144
 
@@ -166,6 +152,8 @@ Use the public Cloudflare Workers deployment (no local installation required):
166
152
  }
167
153
  ```
168
154
 
155
+ **NOTE**: This service uses the Cloudflare Workers free tier which has a limit of 100,000 requests per month.
156
+
169
157
  **Note**: This uses the public endpoint. You can also deploy your own Workers instance and use that URL instead.
170
158
 
171
159
  ## Available Tools
@@ -226,6 +214,17 @@ ckan_package_search({
226
214
  })
227
215
  ```
228
216
 
217
+ ### Force text-field parser for long OR queries
218
+
219
+ ```typescript
220
+ ckan_package_search({
221
+ server_url: "https://www.dati.gov.it/opendata",
222
+ q: "hotel OR alberghi OR \"strutture ricettive\" OR ospitalità OR ricettività",
223
+ query_parser: "text",
224
+ rows: 0
225
+ })
226
+ ```
227
+
229
228
  ### Rank datasets by relevance
230
229
 
231
230
  ```typescript
@@ -477,28 +476,32 @@ ckan_package_search({
477
476
  ```
478
477
  ckan-mcp-server/
479
478
  ├── src/
480
- │ ├── index.ts # Entry point
481
- │ ├── server.ts # MCP server setup
482
- │ ├── types.ts # Types & schemas
479
+ │ ├── index.ts # Entry point
480
+ │ ├── server.ts # MCP server setup
481
+ │ ├── worker.ts # Cloudflare Workers entry
482
+ │ ├── types.ts # Types & schemas
483
483
  │ ├── utils/
484
- │ │ ├── http.ts # CKAN API client
485
- │ │ └── formatting.ts # Output formatting
484
+ │ │ ├── http.ts # CKAN API client
485
+ │ │ ├── formatting.ts # Output formatting
486
+ │ │ └── url-generator.ts
486
487
  │ ├── tools/
487
- │ │ ├── package.ts # Package search/show
488
+ │ │ ├── package.ts # Package search/show
488
489
  │ │ ├── organization.ts # Organization tools
489
- │ │ ├── datastore.ts # DataStore queries
490
- │ │ └── status.ts # Server status
491
- │ ├── resources/ # MCP Resource Templates
490
+ │ │ ├── datastore.ts # DataStore queries
491
+ │ │ ├── status.ts # Server status
492
+ ├── tag.ts # Tag tools
493
+ │ │ └── group.ts # Group tools
494
+ │ ├── resources/ # MCP Resource Templates
492
495
  │ │ ├── index.ts
493
- │ │ ├── uri.ts # URI parsing
496
+ │ │ ├── uri.ts # URI parsing
494
497
  │ │ ├── dataset.ts
495
498
  │ │ ├── resource.ts
496
499
  │ │ └── organization.ts
497
500
  │ └── transport/
498
- │ ├── stdio.ts # Stdio transport
499
- │ └── http.ts # HTTP transport
500
- ├── tests/ # Test suite (113 tests)
501
- ├── dist/ # Compiled files (generated)
501
+ │ ├── stdio.ts # Stdio transport
502
+ │ └── http.ts # HTTP transport
503
+ ├── tests/ # Test suite (120 tests)
504
+ ├── dist/ # Compiled files (generated)
502
505
  ├── package.json
503
506
  └── README.md
504
507
  ```
package/dist/index.js CHANGED
@@ -90,41 +90,82 @@ 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
129
170
  var DEFAULT_RELEVANCE_WEIGHTS = {
130
171
  title: 4,
@@ -221,6 +262,11 @@ function registerPackageTools(server2) {
221
262
  Supports full Solr search capabilities including filters, facets, and sorting.
222
263
  Use this to discover datasets matching specific criteria.
223
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
+
224
270
  Args:
225
271
  - server_url (string): Base URL of CKAN server (e.g., "https://dati.gov.it/opendata")
226
272
  - q (string): Search query using Solr syntax (default: "*:*" for all)
@@ -231,6 +277,7 @@ Args:
231
277
  - facet_field (array): Fields to facet on (e.g., ["organization", "tags"])
232
278
  - facet_limit (number): Max facet values per field (default: 50)
233
279
  - include_drafts (boolean): Include draft datasets (default: false)
280
+ - query_parser ('default' | 'text'): Override search parser behavior
234
281
  - response_format ('markdown' | 'json'): Output format
235
282
 
236
283
  Returns:
@@ -300,6 +347,7 @@ Examples:
300
347
  facet_field: z2.array(z2.string()).optional().describe("Fields to facet on"),
301
348
  facet_limit: z2.number().int().min(1).optional().default(50).describe("Maximum facet values per field"),
302
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)"),
303
351
  response_format: ResponseFormatSchema
304
352
  }).strict(),
305
353
  annotations: {
@@ -311,8 +359,13 @@ Examples:
311
359
  },
312
360
  async (params) => {
313
361
  try {
362
+ const { effectiveQuery } = resolveSearchQuery(
363
+ params.server_url,
364
+ params.q,
365
+ params.query_parser
366
+ );
314
367
  const apiParams = {
315
- q: params.q,
368
+ q: effectiveQuery,
316
369
  rows: params.rows,
317
370
  start: params.start,
318
371
  include_private: params.include_drafts
@@ -338,6 +391,8 @@ Examples:
338
391
 
339
392
  **Server**: ${params.server_url}
340
393
  **Query**: ${params.q}
394
+ ${effectiveQuery !== params.q ? `**Effective Query**: ${effectiveQuery}
395
+ ` : ""}
341
396
  ${params.fq ? `**Filter**: ${params.fq}
342
397
  ` : ""}
343
398
  **Total Results**: ${result.count}
@@ -356,6 +411,15 @@ ${params.fq ? `**Filter**: ${params.fq}
356
411
  const sorted = Object.entries(facetValues).sort((a, b) => b[1] - a[1]).slice(0, 10);
357
412
  for (const [value, count] of sorted) {
358
413
  markdown += `- **${value}**: ${count}
414
+ `;
415
+ }
416
+ if (Object.keys(facetValues).length > sorted.length) {
417
+ markdown += `
418
+ Note: showing top ${sorted.length} only. Use \`response_format: json\` or increase \`facet_limit\`.
419
+ `;
420
+ } else {
421
+ markdown += `
422
+ Note: showing top ${sorted.length} only. Use \`response_format: json\` for full list.
359
423
  `;
360
424
  }
361
425
  markdown += "\n";
@@ -433,6 +497,7 @@ Args:
433
497
  - query (string): Search query text
434
498
  - limit (number): Number of datasets to return (default: 10)
435
499
  - weights (object): Optional weights for title/notes/tags/organization
500
+ - query_parser ('default' | 'text'): Override search parser behavior
436
501
  - response_format ('markdown' | 'json'): Output format
437
502
 
438
503
  Returns:
@@ -451,6 +516,7 @@ Examples:
451
516
  tags: z2.number().min(0).optional(),
452
517
  organization: z2.number().min(0).optional()
453
518
  }).optional().describe("Optional weights per field"),
519
+ query_parser: z2.enum(["default", "text"]).optional().describe("Override search parser ('text' forces text:(...) on non-fielded queries)"),
454
520
  response_format: ResponseFormatSchema
455
521
  }).strict(),
456
522
  annotations: {
@@ -467,11 +533,16 @@ Examples:
467
533
  ...params.weights ?? {}
468
534
  };
469
535
  const rows = Math.min(Math.max(params.limit * 5, params.limit), 100);
536
+ const { effectiveQuery } = resolveSearchQuery(
537
+ params.server_url,
538
+ params.query,
539
+ params.query_parser
540
+ );
470
541
  const searchResult = await makeCkanRequest(
471
542
  params.server_url,
472
543
  "package_search",
473
544
  {
474
- q: params.query,
545
+ q: effectiveQuery,
475
546
  rows,
476
547
  start: 0
477
548
  }
@@ -835,16 +906,84 @@ Returns:
835
906
  content: [{ type: "text", text: markdown2 }]
836
907
  };
837
908
  }
838
- const result = await makeCkanRequest(
839
- params.server_url,
840
- "organization_list",
841
- {
842
- all_fields: params.all_fields,
843
- sort: params.sort,
844
- limit: params.limit,
845
- offset: params.offset
909
+ let result;
910
+ try {
911
+ result = await makeCkanRequest(
912
+ params.server_url,
913
+ "organization_list",
914
+ {
915
+ all_fields: params.all_fields,
916
+ sort: params.sort,
917
+ limit: params.limit,
918
+ offset: params.offset
919
+ }
920
+ );
921
+ } catch (error) {
922
+ const message = error instanceof Error ? error.message : String(error);
923
+ if (message.includes("CKAN API error (500)")) {
924
+ const searchResult = await makeCkanRequest(
925
+ params.server_url,
926
+ "package_search",
927
+ {
928
+ rows: 0,
929
+ "facet.field": JSON.stringify(["organization"]),
930
+ "facet.limit": -1
931
+ }
932
+ );
933
+ const items = searchResult.search_facets?.organization?.items || [];
934
+ const sortValue = params.sort?.toLowerCase() ?? "name asc";
935
+ const sortedItems = [...items].sort((a, b) => {
936
+ if (sortValue.includes("package_count") || sortValue.includes("count")) {
937
+ return b.count - a.count;
938
+ }
939
+ if (sortValue.includes("name desc")) {
940
+ return String(b.name).localeCompare(String(a.name));
941
+ }
942
+ return String(a.name).localeCompare(String(b.name));
943
+ });
944
+ const pagedItems = sortedItems.slice(params.offset, params.offset + params.limit);
945
+ const organizations = pagedItems.map((item) => ({
946
+ id: item.name,
947
+ name: item.name,
948
+ title: item.display_name || item.name,
949
+ package_count: item.count
950
+ }));
951
+ if (params.response_format === "json" /* JSON */) {
952
+ const output = { count: items.length, organizations };
953
+ return {
954
+ content: [{ type: "text", text: truncateText(JSON.stringify(output, null, 2)) }],
955
+ structuredContent: output
956
+ };
957
+ }
958
+ let markdown2 = `# CKAN Organizations
959
+
960
+ `;
961
+ markdown2 += `**Server**: ${params.server_url}
962
+ `;
963
+ markdown2 += `**Total**: ${items.length}
964
+ `;
965
+ markdown2 += `
966
+ Note: organization_list returned 500; using package_search facets.
967
+
968
+ `;
969
+ for (const org of organizations) {
970
+ markdown2 += `## ${org.title || org.name}
971
+
972
+ `;
973
+ markdown2 += `- **Name**: \`${org.name}\`
974
+ `;
975
+ markdown2 += `- **Datasets**: ${org.package_count || 0}
976
+ `;
977
+ markdown2 += `- **Link**: ${getOrganizationViewUrl(params.server_url, org)}
978
+
979
+ `;
980
+ }
981
+ return {
982
+ content: [{ type: "text", text: truncateText(markdown2) }]
983
+ };
846
984
  }
847
- );
985
+ throw error;
986
+ }
848
987
  if (params.response_format === "json" /* JSON */) {
849
988
  const output = Array.isArray(result) ? { count: result.length, organizations: result } : result;
850
989
  return {
@@ -2087,7 +2226,7 @@ function registerAllResources(server2) {
2087
2226
  function createServer() {
2088
2227
  return new McpServer({
2089
2228
  name: "ckan-mcp-server",
2090
- version: "0.4.5"
2229
+ version: "0.4.8"
2091
2230
  });
2092
2231
  }
2093
2232
  function registerAll(server2) {