@aborruso/ckan-mcp-server 0.3.1
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/.claude/commands/openspec/apply.md +23 -0
- package/.claude/commands/openspec/archive.md +27 -0
- package/.claude/commands/openspec/proposal.md +28 -0
- package/.claude/settings.local.json +31 -0
- package/.gemini/commands/openspec/apply.toml +21 -0
- package/.gemini/commands/openspec/archive.toml +25 -0
- package/.gemini/commands/openspec/proposal.toml +26 -0
- package/.mcp.json +12 -0
- package/.opencode/command/openspec-apply.md +24 -0
- package/.opencode/command/openspec-archive.md +27 -0
- package/.opencode/command/openspec-proposal.md +29 -0
- package/AGENTS.md +18 -0
- package/CLAUDE.md +320 -0
- package/EXAMPLES.md +707 -0
- package/LICENSE.txt +21 -0
- package/LOG.md +154 -0
- package/PRD.md +912 -0
- package/README.md +468 -0
- package/REFACTORING.md +237 -0
- package/dist/index.js +1277 -0
- package/openspec/AGENTS.md +456 -0
- package/openspec/changes/archive/2026-01-08-add-mcp-resources/design.md +115 -0
- package/openspec/changes/archive/2026-01-08-add-mcp-resources/proposal.md +52 -0
- package/openspec/changes/archive/2026-01-08-add-mcp-resources/specs/mcp-resources/spec.md +92 -0
- package/openspec/changes/archive/2026-01-08-add-mcp-resources/tasks.md +56 -0
- package/openspec/changes/archive/2026-01-08-expand-test-coverage-specs/design.md +355 -0
- package/openspec/changes/archive/2026-01-08-expand-test-coverage-specs/proposal.md +161 -0
- package/openspec/changes/archive/2026-01-08-expand-test-coverage-specs/tasks.md +162 -0
- package/openspec/changes/archive/2026-01-08-translate-project-to-english/proposal.md +115 -0
- package/openspec/changes/archive/2026-01-08-translate-project-to-english/specs/documentation-language/spec.md +32 -0
- package/openspec/changes/archive/2026-01-08-translate-project-to-english/tasks.md +115 -0
- package/openspec/changes/archive/add-automated-tests/design.md +324 -0
- package/openspec/changes/archive/add-automated-tests/proposal.md +167 -0
- package/openspec/changes/archive/add-automated-tests/specs/automated-testing/spec.md +143 -0
- package/openspec/changes/archive/add-automated-tests/tasks.md +132 -0
- package/openspec/project.md +113 -0
- package/openspec/specs/documentation-language/spec.md +32 -0
- package/openspec/specs/mcp-resources/spec.md +94 -0
- package/package.json +46 -0
- package/spunti.md +19 -0
- package/tasks/todo.md +124 -0
- package/test-urls.js +18 -0
- package/tmp/test-org-search.js +55 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/server.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
function createServer() {
|
|
6
|
+
return new McpServer({
|
|
7
|
+
name: "ckan-mcp-server",
|
|
8
|
+
version: "0.1.0"
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// src/tools/package.ts
|
|
13
|
+
import { z as z2 } from "zod";
|
|
14
|
+
|
|
15
|
+
// src/types.ts
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
var ResponseFormat = /* @__PURE__ */ ((ResponseFormat2) => {
|
|
18
|
+
ResponseFormat2["MARKDOWN"] = "markdown";
|
|
19
|
+
ResponseFormat2["JSON"] = "json";
|
|
20
|
+
return ResponseFormat2;
|
|
21
|
+
})(ResponseFormat || {});
|
|
22
|
+
var ResponseFormatSchema = z.nativeEnum(ResponseFormat).default("markdown" /* MARKDOWN */).describe("Output format: 'markdown' for human-readable or 'json' for machine-readable");
|
|
23
|
+
var CHARACTER_LIMIT = 5e4;
|
|
24
|
+
|
|
25
|
+
// src/utils/http.ts
|
|
26
|
+
import axios from "axios";
|
|
27
|
+
async function makeCkanRequest(serverUrl, action, params = {}) {
|
|
28
|
+
const baseUrl = serverUrl.replace(/\/$/, "");
|
|
29
|
+
const url = `${baseUrl}/api/3/action/${action}`;
|
|
30
|
+
try {
|
|
31
|
+
const response = await axios.get(url, {
|
|
32
|
+
params,
|
|
33
|
+
timeout: 3e4,
|
|
34
|
+
headers: {
|
|
35
|
+
"User-Agent": "CKAN-MCP-Server/1.0"
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
if (response.data && response.data.success === true) {
|
|
39
|
+
return response.data.result;
|
|
40
|
+
} else {
|
|
41
|
+
throw new Error(`CKAN API returned success=false: ${JSON.stringify(response.data)}`);
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (axios.isAxiosError(error)) {
|
|
45
|
+
const axiosError = error;
|
|
46
|
+
if (axiosError.response) {
|
|
47
|
+
const status = axiosError.response.status;
|
|
48
|
+
const data = axiosError.response.data;
|
|
49
|
+
const errorMsg = data?.error?.message || data?.error || "Unknown error";
|
|
50
|
+
throw new Error(`CKAN API error (${status}): ${errorMsg}`);
|
|
51
|
+
} else if (axiosError.code === "ECONNABORTED") {
|
|
52
|
+
throw new Error(`Request timeout connecting to ${serverUrl}`);
|
|
53
|
+
} else if (axiosError.code === "ENOTFOUND") {
|
|
54
|
+
throw new Error(`Server not found: ${serverUrl}`);
|
|
55
|
+
} else {
|
|
56
|
+
throw new Error(`Network error: ${axiosError.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/utils/formatting.ts
|
|
64
|
+
function truncateText(text, limit = CHARACTER_LIMIT) {
|
|
65
|
+
if (text.length <= limit) {
|
|
66
|
+
return text;
|
|
67
|
+
}
|
|
68
|
+
return text.substring(0, limit) + `
|
|
69
|
+
|
|
70
|
+
... [Response truncated at ${limit} characters]`;
|
|
71
|
+
}
|
|
72
|
+
function formatDate(dateStr) {
|
|
73
|
+
try {
|
|
74
|
+
return new Date(dateStr).toLocaleString("it-IT");
|
|
75
|
+
} catch {
|
|
76
|
+
return dateStr;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// src/portals.json
|
|
81
|
+
var portals_default = {
|
|
82
|
+
portals: [
|
|
83
|
+
{
|
|
84
|
+
id: "dati-gov-it",
|
|
85
|
+
name: "dati.gov.it",
|
|
86
|
+
api_url: "https://www.dati.gov.it/opendata",
|
|
87
|
+
api_url_aliases: [
|
|
88
|
+
"https://dati.gov.it/opendata",
|
|
89
|
+
"http://www.dati.gov.it/opendata",
|
|
90
|
+
"http://dati.gov.it/opendata"
|
|
91
|
+
],
|
|
92
|
+
dataset_view_url: "https://www.dati.gov.it/view-dataset/dataset?id={id}",
|
|
93
|
+
organization_view_url: "https://www.dati.gov.it/view-dataset?organization={name}"
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
defaults: {
|
|
97
|
+
dataset_view_url: "{server_url}/dataset/{name}",
|
|
98
|
+
organization_view_url: "{server_url}/organization/{name}"
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// src/utils/url-generator.ts
|
|
103
|
+
function normalizeUrl(url) {
|
|
104
|
+
return url.replace(/\/$/, "");
|
|
105
|
+
}
|
|
106
|
+
function getDatasetViewUrl(serverUrl, pkg) {
|
|
107
|
+
const cleanServerUrl = normalizeUrl(serverUrl);
|
|
108
|
+
const portal = portals_default.portals.find((p) => {
|
|
109
|
+
const mainUrl = normalizeUrl(p.api_url);
|
|
110
|
+
const aliases = (p.api_url_aliases || []).map(normalizeUrl);
|
|
111
|
+
return mainUrl === cleanServerUrl || aliases.includes(cleanServerUrl);
|
|
112
|
+
});
|
|
113
|
+
const template = portal?.dataset_view_url || portals_default.defaults.dataset_view_url;
|
|
114
|
+
return template.replace("{server_url}", cleanServerUrl).replace("{id}", pkg.id).replace("{name}", pkg.name);
|
|
115
|
+
}
|
|
116
|
+
function getOrganizationViewUrl(serverUrl, org) {
|
|
117
|
+
const cleanServerUrl = normalizeUrl(serverUrl);
|
|
118
|
+
const portal = portals_default.portals.find((p) => {
|
|
119
|
+
const mainUrl = normalizeUrl(p.api_url);
|
|
120
|
+
const aliases = (p.api_url_aliases || []).map(normalizeUrl);
|
|
121
|
+
return mainUrl === cleanServerUrl || aliases.includes(cleanServerUrl);
|
|
122
|
+
});
|
|
123
|
+
const template = portal?.organization_view_url || portals_default.defaults.organization_view_url;
|
|
124
|
+
return template.replace("{server_url}", cleanServerUrl).replace("{id}", org.id).replace("{name}", org.name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/tools/package.ts
|
|
128
|
+
function registerPackageTools(server2) {
|
|
129
|
+
server2.registerTool(
|
|
130
|
+
"ckan_package_search",
|
|
131
|
+
{
|
|
132
|
+
title: "Search CKAN Datasets",
|
|
133
|
+
description: `Search for datasets (packages) on a CKAN server using Solr query syntax.
|
|
134
|
+
|
|
135
|
+
Supports full Solr search capabilities including filters, facets, and sorting.
|
|
136
|
+
Use this to discover datasets matching specific criteria.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
- server_url (string): Base URL of CKAN server (e.g., "https://dati.gov.it")
|
|
140
|
+
- q (string): Search query using Solr syntax (default: "*:*" for all)
|
|
141
|
+
- fq (string): Filter query (e.g., "organization:comune-palermo")
|
|
142
|
+
- rows (number): Number of results to return (default: 10, max: 1000)
|
|
143
|
+
- start (number): Offset for pagination (default: 0)
|
|
144
|
+
- sort (string): Sort field and direction (e.g., "metadata_modified desc")
|
|
145
|
+
- facet_field (array): Fields to facet on (e.g., ["organization", "tags"])
|
|
146
|
+
- facet_limit (number): Max facet values per field (default: 50)
|
|
147
|
+
- include_drafts (boolean): Include draft datasets (default: false)
|
|
148
|
+
- response_format ('markdown' | 'json'): Output format
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Search results with:
|
|
152
|
+
- count: Number of results found
|
|
153
|
+
- results: Array of dataset objects
|
|
154
|
+
- facets: Facet counts (if facet_field specified)
|
|
155
|
+
- search_facets: Detailed facet information
|
|
156
|
+
|
|
157
|
+
Query Syntax (parameter q):
|
|
158
|
+
Boolean operators:
|
|
159
|
+
- AND / &&: "water AND climate"
|
|
160
|
+
- OR / ||: "health OR sanit\xE0"
|
|
161
|
+
- NOT / !: "data NOT personal"
|
|
162
|
+
- +required -excluded: "+title:water -title:sea"
|
|
163
|
+
- Grouping: "(title:water OR title:climate) AND tags:environment"
|
|
164
|
+
|
|
165
|
+
Wildcards:
|
|
166
|
+
- *: "title:environment*" (matches environmental, environments, etc.)
|
|
167
|
+
- Note: Left truncation (*water) not supported
|
|
168
|
+
|
|
169
|
+
Fuzzy search (edit distance):
|
|
170
|
+
- ~: "title:rest~" or "title:rest~1" (finds "test", "best", "rest")
|
|
171
|
+
|
|
172
|
+
Proximity search (words within N positions):
|
|
173
|
+
- "phrase"~N: "title:"climate change"~5"
|
|
174
|
+
|
|
175
|
+
Range queries:
|
|
176
|
+
- Inclusive [a TO b]: "num_resources:[5 TO 10]"
|
|
177
|
+
- Exclusive {a TO b}: "num_resources:{0 TO 100}"
|
|
178
|
+
- One side open: "metadata_modified:[2024-01-01T00:00:00Z TO *]"
|
|
179
|
+
|
|
180
|
+
Date math:
|
|
181
|
+
- NOW-1YEAR, NOW-6MONTHS, NOW-7DAYS, NOW-1HOUR
|
|
182
|
+
- NOW/DAY, NOW/MONTH (round down)
|
|
183
|
+
- Combined: "metadata_modified:[NOW-2MONTHS TO NOW]"
|
|
184
|
+
- Example: "metadata_created:[NOW-1YEAR TO *]"
|
|
185
|
+
|
|
186
|
+
Field existence:
|
|
187
|
+
- Exists: "field:*" or "field:[* TO *]"
|
|
188
|
+
- Not exists: "NOT field:*" or "-field:*"
|
|
189
|
+
|
|
190
|
+
Boosting (relevance scoring):
|
|
191
|
+
- Boost term: "title:water^2 OR notes:water" (title matches score higher)
|
|
192
|
+
- Constant score: "title:water^=1.5"
|
|
193
|
+
|
|
194
|
+
Examples:
|
|
195
|
+
- Search all: { q: "*:*" }
|
|
196
|
+
- By tag: { q: "tags:sanit\xE0" }
|
|
197
|
+
- Boolean: { q: "(title:water OR title:climate) AND NOT title:sea" }
|
|
198
|
+
- Wildcard: { q: "title:environment*" }
|
|
199
|
+
- Fuzzy: { q: "title:health~2" }
|
|
200
|
+
- Proximity: { q: "notes:"open data"~3" }
|
|
201
|
+
- Date range: { q: "metadata_modified:[2024-01-01T00:00:00Z TO 2024-12-31T23:59:59Z]" }
|
|
202
|
+
- Date math: { q: "metadata_modified:[NOW-6MONTHS TO *]" }
|
|
203
|
+
- Field exists: { q: "organization:* AND num_resources:[1 TO *]" }
|
|
204
|
+
- Boosting: { q: "title:climate^2 OR notes:climate" }
|
|
205
|
+
- Filter org: { fq: "organization:regione-siciliana" }
|
|
206
|
+
- Get facets: { facet_field: ["organization"], rows: 0 }`,
|
|
207
|
+
inputSchema: z2.object({
|
|
208
|
+
server_url: z2.string().url("Must be a valid URL").describe("Base URL of the CKAN server"),
|
|
209
|
+
q: z2.string().optional().default("*:*").describe("Search query in Solr syntax"),
|
|
210
|
+
fq: z2.string().optional().describe("Filter query in Solr syntax"),
|
|
211
|
+
rows: z2.number().int().min(0).max(1e3).optional().default(10).describe("Number of results to return"),
|
|
212
|
+
start: z2.number().int().min(0).optional().default(0).describe("Offset for pagination"),
|
|
213
|
+
sort: z2.string().optional().describe("Sort field and direction (e.g., 'metadata_modified desc')"),
|
|
214
|
+
facet_field: z2.array(z2.string()).optional().describe("Fields to facet on"),
|
|
215
|
+
facet_limit: z2.number().int().min(1).optional().default(50).describe("Maximum facet values per field"),
|
|
216
|
+
include_drafts: z2.boolean().optional().default(false).describe("Include draft datasets"),
|
|
217
|
+
response_format: ResponseFormatSchema
|
|
218
|
+
}).strict(),
|
|
219
|
+
annotations: {
|
|
220
|
+
readOnlyHint: true,
|
|
221
|
+
destructiveHint: false,
|
|
222
|
+
idempotentHint: true,
|
|
223
|
+
openWorldHint: true
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
async (params) => {
|
|
227
|
+
try {
|
|
228
|
+
const apiParams = {
|
|
229
|
+
q: params.q,
|
|
230
|
+
rows: params.rows,
|
|
231
|
+
start: params.start,
|
|
232
|
+
include_private: params.include_drafts
|
|
233
|
+
};
|
|
234
|
+
if (params.fq) apiParams.fq = params.fq;
|
|
235
|
+
if (params.sort) apiParams.sort = params.sort;
|
|
236
|
+
if (params.facet_field && params.facet_field.length > 0) {
|
|
237
|
+
apiParams["facet.field"] = JSON.stringify(params.facet_field);
|
|
238
|
+
apiParams["facet.limit"] = params.facet_limit;
|
|
239
|
+
}
|
|
240
|
+
const result = await makeCkanRequest(
|
|
241
|
+
params.server_url,
|
|
242
|
+
"package_search",
|
|
243
|
+
apiParams
|
|
244
|
+
);
|
|
245
|
+
if (params.response_format === "json" /* JSON */) {
|
|
246
|
+
return {
|
|
247
|
+
content: [{ type: "text", text: truncateText(JSON.stringify(result, null, 2)) }],
|
|
248
|
+
structuredContent: result
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
let markdown = `# CKAN Package Search Results
|
|
252
|
+
|
|
253
|
+
**Server**: ${params.server_url}
|
|
254
|
+
**Query**: ${params.q}
|
|
255
|
+
${params.fq ? `**Filter**: ${params.fq}
|
|
256
|
+
` : ""}
|
|
257
|
+
**Total Results**: ${result.count}
|
|
258
|
+
**Showing**: ${result.results.length} results (from ${params.start})
|
|
259
|
+
|
|
260
|
+
`;
|
|
261
|
+
if (result.facets && Object.keys(result.facets).length > 0) {
|
|
262
|
+
markdown += `## Facets
|
|
263
|
+
|
|
264
|
+
`;
|
|
265
|
+
for (const [field, values] of Object.entries(result.facets)) {
|
|
266
|
+
markdown += `### ${field}
|
|
267
|
+
|
|
268
|
+
`;
|
|
269
|
+
const facetValues = values;
|
|
270
|
+
const sorted = Object.entries(facetValues).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
271
|
+
for (const [value, count] of sorted) {
|
|
272
|
+
markdown += `- **${value}**: ${count}
|
|
273
|
+
`;
|
|
274
|
+
}
|
|
275
|
+
markdown += "\n";
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (result.results && result.results.length > 0) {
|
|
279
|
+
markdown += `## Datasets
|
|
280
|
+
|
|
281
|
+
`;
|
|
282
|
+
for (const pkg of result.results) {
|
|
283
|
+
markdown += `### ${pkg.title || pkg.name}
|
|
284
|
+
|
|
285
|
+
`;
|
|
286
|
+
markdown += `- **ID**: \`${pkg.id}\`
|
|
287
|
+
`;
|
|
288
|
+
markdown += `- **Name**: \`${pkg.name}\`
|
|
289
|
+
`;
|
|
290
|
+
if (pkg.organization) {
|
|
291
|
+
markdown += `- **Organization**: ${pkg.organization.title || pkg.organization.name}
|
|
292
|
+
`;
|
|
293
|
+
}
|
|
294
|
+
if (pkg.notes) {
|
|
295
|
+
const notes = pkg.notes.substring(0, 200);
|
|
296
|
+
markdown += `- **Description**: ${notes}${pkg.notes.length > 200 ? "..." : ""}
|
|
297
|
+
`;
|
|
298
|
+
}
|
|
299
|
+
if (pkg.tags && pkg.tags.length > 0) {
|
|
300
|
+
const tags = pkg.tags.slice(0, 5).map((t) => t.name).join(", ");
|
|
301
|
+
markdown += `- **Tags**: ${tags}${pkg.tags.length > 5 ? ", ..." : ""}
|
|
302
|
+
`;
|
|
303
|
+
}
|
|
304
|
+
markdown += `- **Resources**: ${pkg.num_resources || 0}
|
|
305
|
+
`;
|
|
306
|
+
markdown += `- **Modified**: ${formatDate(pkg.metadata_modified)}
|
|
307
|
+
`;
|
|
308
|
+
markdown += `- **Link**: ${getDatasetViewUrl(params.server_url, pkg)}
|
|
309
|
+
|
|
310
|
+
`;
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
markdown += `No datasets found matching your query.
|
|
314
|
+
`;
|
|
315
|
+
}
|
|
316
|
+
if (result.count > params.start + params.rows) {
|
|
317
|
+
const nextStart = params.start + params.rows;
|
|
318
|
+
markdown += `
|
|
319
|
+
---
|
|
320
|
+
**More results available**: Use \`start: ${nextStart}\` to see next page.
|
|
321
|
+
`;
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
content: [{ type: "text", text: truncateText(markdown) }]
|
|
325
|
+
};
|
|
326
|
+
} catch (error) {
|
|
327
|
+
return {
|
|
328
|
+
content: [{
|
|
329
|
+
type: "text",
|
|
330
|
+
text: `Error searching packages: ${error instanceof Error ? error.message : String(error)}`
|
|
331
|
+
}],
|
|
332
|
+
isError: true
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
server2.registerTool(
|
|
338
|
+
"ckan_package_show",
|
|
339
|
+
{
|
|
340
|
+
title: "Show CKAN Dataset Details",
|
|
341
|
+
description: `Get complete metadata for a specific dataset (package).
|
|
342
|
+
|
|
343
|
+
Returns full details including resources, organization, tags, and all metadata fields.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
- server_url (string): Base URL of CKAN server
|
|
347
|
+
- id (string): Dataset ID or name (machine-readable slug)
|
|
348
|
+
- include_tracking (boolean): Include view/download statistics (default: false)
|
|
349
|
+
- response_format ('markdown' | 'json'): Output format
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Complete dataset object with all metadata and resources
|
|
353
|
+
|
|
354
|
+
Examples:
|
|
355
|
+
- { server_url: "https://dati.gov.it", id: "dataset-name" }
|
|
356
|
+
- { server_url: "...", id: "abc-123-def", include_tracking: true }`,
|
|
357
|
+
inputSchema: z2.object({
|
|
358
|
+
server_url: z2.string().url().describe("Base URL of the CKAN server"),
|
|
359
|
+
id: z2.string().min(1).describe("Dataset ID or name"),
|
|
360
|
+
include_tracking: z2.boolean().optional().default(false).describe("Include tracking statistics"),
|
|
361
|
+
response_format: ResponseFormatSchema
|
|
362
|
+
}).strict(),
|
|
363
|
+
annotations: {
|
|
364
|
+
readOnlyHint: true,
|
|
365
|
+
destructiveHint: false,
|
|
366
|
+
idempotentHint: true,
|
|
367
|
+
openWorldHint: false
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
async (params) => {
|
|
371
|
+
try {
|
|
372
|
+
const result = await makeCkanRequest(
|
|
373
|
+
params.server_url,
|
|
374
|
+
"package_show",
|
|
375
|
+
{
|
|
376
|
+
id: params.id,
|
|
377
|
+
include_tracking: params.include_tracking
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
if (params.response_format === "json" /* JSON */) {
|
|
381
|
+
return {
|
|
382
|
+
content: [{ type: "text", text: truncateText(JSON.stringify(result, null, 2)) }],
|
|
383
|
+
structuredContent: result
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
let markdown = `# Dataset: ${result.title || result.name}
|
|
387
|
+
|
|
388
|
+
`;
|
|
389
|
+
markdown += `**Server**: ${params.server_url}
|
|
390
|
+
`;
|
|
391
|
+
markdown += `**Link**: ${getDatasetViewUrl(params.server_url, result)}
|
|
392
|
+
|
|
393
|
+
`;
|
|
394
|
+
markdown += `## Basic Information
|
|
395
|
+
|
|
396
|
+
`;
|
|
397
|
+
markdown += `- **ID**: \`${result.id}\`
|
|
398
|
+
`;
|
|
399
|
+
markdown += `- **Name**: \`${result.name}\`
|
|
400
|
+
`;
|
|
401
|
+
if (result.author) markdown += `- **Author**: ${result.author}
|
|
402
|
+
`;
|
|
403
|
+
if (result.author_email) markdown += `- **Author Email**: ${result.author_email}
|
|
404
|
+
`;
|
|
405
|
+
if (result.maintainer) markdown += `- **Maintainer**: ${result.maintainer}
|
|
406
|
+
`;
|
|
407
|
+
if (result.maintainer_email) markdown += `- **Maintainer Email**: ${result.maintainer_email}
|
|
408
|
+
`;
|
|
409
|
+
markdown += `- **License**: ${result.license_title || result.license_id || "Not specified"}
|
|
410
|
+
`;
|
|
411
|
+
markdown += `- **State**: ${result.state}
|
|
412
|
+
`;
|
|
413
|
+
markdown += `- **Created**: ${formatDate(result.metadata_created)}
|
|
414
|
+
`;
|
|
415
|
+
markdown += `- **Modified**: ${formatDate(result.metadata_modified)}
|
|
416
|
+
|
|
417
|
+
`;
|
|
418
|
+
if (result.organization) {
|
|
419
|
+
markdown += `## Organization
|
|
420
|
+
|
|
421
|
+
`;
|
|
422
|
+
markdown += `- **Name**: ${result.organization.title || result.organization.name}
|
|
423
|
+
`;
|
|
424
|
+
markdown += `- **ID**: \`${result.organization.id}\`
|
|
425
|
+
|
|
426
|
+
`;
|
|
427
|
+
}
|
|
428
|
+
if (result.notes) {
|
|
429
|
+
markdown += `## Description
|
|
430
|
+
|
|
431
|
+
${result.notes}
|
|
432
|
+
|
|
433
|
+
`;
|
|
434
|
+
}
|
|
435
|
+
if (result.tags && result.tags.length > 0) {
|
|
436
|
+
markdown += `## Tags
|
|
437
|
+
|
|
438
|
+
`;
|
|
439
|
+
markdown += result.tags.map((t) => `- ${t.name}`).join("\n") + "\n\n";
|
|
440
|
+
}
|
|
441
|
+
if (result.groups && result.groups.length > 0) {
|
|
442
|
+
markdown += `## Groups
|
|
443
|
+
|
|
444
|
+
`;
|
|
445
|
+
for (const group of result.groups) {
|
|
446
|
+
markdown += `- **${group.title || group.name}** (\`${group.name}\`)
|
|
447
|
+
`;
|
|
448
|
+
}
|
|
449
|
+
markdown += "\n";
|
|
450
|
+
}
|
|
451
|
+
if (result.resources && result.resources.length > 0) {
|
|
452
|
+
markdown += `## Resources (${result.resources.length})
|
|
453
|
+
|
|
454
|
+
`;
|
|
455
|
+
for (const resource of result.resources) {
|
|
456
|
+
markdown += `### ${resource.name || "Unnamed Resource"}
|
|
457
|
+
|
|
458
|
+
`;
|
|
459
|
+
markdown += `- **ID**: \`${resource.id}\`
|
|
460
|
+
`;
|
|
461
|
+
markdown += `- **Format**: ${resource.format || "Unknown"}
|
|
462
|
+
`;
|
|
463
|
+
if (resource.description) markdown += `- **Description**: ${resource.description}
|
|
464
|
+
`;
|
|
465
|
+
markdown += `- **URL**: ${resource.url}
|
|
466
|
+
`;
|
|
467
|
+
if (resource.size) {
|
|
468
|
+
const formatBytes = (bytes) => {
|
|
469
|
+
if (!bytes || bytes === 0) return "0 B";
|
|
470
|
+
const k = 1024;
|
|
471
|
+
const sizes = ["B", "KB", "MB", "GB"];
|
|
472
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
473
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
|
474
|
+
};
|
|
475
|
+
markdown += `- **Size**: ${formatBytes(resource.size)}
|
|
476
|
+
`;
|
|
477
|
+
}
|
|
478
|
+
if (resource.mimetype) markdown += `- **MIME Type**: ${resource.mimetype}
|
|
479
|
+
`;
|
|
480
|
+
markdown += `- **Created**: ${formatDate(resource.created)}
|
|
481
|
+
`;
|
|
482
|
+
if (resource.last_modified) markdown += `- **Modified**: ${formatDate(resource.last_modified)}
|
|
483
|
+
`;
|
|
484
|
+
if (resource.datastore_active !== void 0) {
|
|
485
|
+
markdown += `- **DataStore**: ${resource.datastore_active ? "\u2705 Available" : "\u274C Not available"}
|
|
486
|
+
`;
|
|
487
|
+
}
|
|
488
|
+
markdown += "\n";
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (result.extras && result.extras.length > 0) {
|
|
492
|
+
markdown += `## Extra Fields
|
|
493
|
+
|
|
494
|
+
`;
|
|
495
|
+
for (const extra of result.extras) {
|
|
496
|
+
markdown += `- **${extra.key}**: ${extra.value}
|
|
497
|
+
`;
|
|
498
|
+
}
|
|
499
|
+
markdown += "\n";
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
content: [{ type: "text", text: truncateText(markdown) }]
|
|
503
|
+
};
|
|
504
|
+
} catch (error) {
|
|
505
|
+
return {
|
|
506
|
+
content: [{
|
|
507
|
+
type: "text",
|
|
508
|
+
text: `Error fetching package: ${error instanceof Error ? error.message : String(error)}`
|
|
509
|
+
}],
|
|
510
|
+
isError: true
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/tools/organization.ts
|
|
518
|
+
import { z as z3 } from "zod";
|
|
519
|
+
function registerOrganizationTools(server2) {
|
|
520
|
+
server2.registerTool(
|
|
521
|
+
"ckan_organization_list",
|
|
522
|
+
{
|
|
523
|
+
title: "List CKAN Organizations",
|
|
524
|
+
description: `List all organizations on a CKAN server.
|
|
525
|
+
|
|
526
|
+
Organizations are entities that publish and manage datasets.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
- server_url (string): Base URL of CKAN server
|
|
530
|
+
- all_fields (boolean): Return full objects vs just names (default: false)
|
|
531
|
+
- sort (string): Sort field (default: "name asc")
|
|
532
|
+
- limit (number): Maximum results (default: 100). Use 0 to get only the count via faceting
|
|
533
|
+
- offset (number): Pagination offset (default: 0)
|
|
534
|
+
- response_format ('markdown' | 'json'): Output format
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
List of organizations with metadata. When limit=0, returns only the count of organizations with datasets.`,
|
|
538
|
+
inputSchema: z3.object({
|
|
539
|
+
server_url: z3.string().url(),
|
|
540
|
+
all_fields: z3.boolean().optional().default(false),
|
|
541
|
+
sort: z3.string().optional().default("name asc"),
|
|
542
|
+
limit: z3.number().int().min(0).optional().default(100),
|
|
543
|
+
offset: z3.number().int().min(0).optional().default(0),
|
|
544
|
+
response_format: ResponseFormatSchema
|
|
545
|
+
}).strict(),
|
|
546
|
+
annotations: {
|
|
547
|
+
readOnlyHint: true,
|
|
548
|
+
destructiveHint: false,
|
|
549
|
+
idempotentHint: true,
|
|
550
|
+
openWorldHint: false
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
async (params) => {
|
|
554
|
+
try {
|
|
555
|
+
if (params.limit === 0) {
|
|
556
|
+
const searchResult = await makeCkanRequest(
|
|
557
|
+
params.server_url,
|
|
558
|
+
"package_search",
|
|
559
|
+
{
|
|
560
|
+
rows: 0,
|
|
561
|
+
"facet.field": JSON.stringify(["organization"]),
|
|
562
|
+
"facet.limit": -1
|
|
563
|
+
}
|
|
564
|
+
);
|
|
565
|
+
const orgCount = searchResult.search_facets?.organization?.items?.length || 0;
|
|
566
|
+
if (params.response_format === "json" /* JSON */) {
|
|
567
|
+
return {
|
|
568
|
+
content: [{ type: "text", text: JSON.stringify({ count: orgCount }, null, 2) }],
|
|
569
|
+
structuredContent: { count: orgCount }
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const markdown2 = `# CKAN Organizations Count
|
|
573
|
+
|
|
574
|
+
**Server**: ${params.server_url}
|
|
575
|
+
**Total organizations (with datasets)**: ${orgCount}
|
|
576
|
+
`;
|
|
577
|
+
return {
|
|
578
|
+
content: [{ type: "text", text: markdown2 }]
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
const result = await makeCkanRequest(
|
|
582
|
+
params.server_url,
|
|
583
|
+
"organization_list",
|
|
584
|
+
{
|
|
585
|
+
all_fields: params.all_fields,
|
|
586
|
+
sort: params.sort,
|
|
587
|
+
limit: params.limit,
|
|
588
|
+
offset: params.offset
|
|
589
|
+
}
|
|
590
|
+
);
|
|
591
|
+
if (params.response_format === "json" /* JSON */) {
|
|
592
|
+
const output = Array.isArray(result) ? { count: result.length, organizations: result } : result;
|
|
593
|
+
return {
|
|
594
|
+
content: [{ type: "text", text: truncateText(JSON.stringify(output, null, 2)) }],
|
|
595
|
+
structuredContent: output
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
let markdown = `# CKAN Organizations
|
|
599
|
+
|
|
600
|
+
`;
|
|
601
|
+
markdown += `**Server**: ${params.server_url}
|
|
602
|
+
`;
|
|
603
|
+
markdown += `**Total**: ${Array.isArray(result) ? result.length : "Unknown"}
|
|
604
|
+
|
|
605
|
+
`;
|
|
606
|
+
if (Array.isArray(result)) {
|
|
607
|
+
if (params.all_fields) {
|
|
608
|
+
for (const org of result) {
|
|
609
|
+
markdown += `## ${org.title || org.name}
|
|
610
|
+
|
|
611
|
+
`;
|
|
612
|
+
markdown += `- **ID**: \`${org.id}\`
|
|
613
|
+
`;
|
|
614
|
+
markdown += `- **Name**: \`${org.name}\`
|
|
615
|
+
`;
|
|
616
|
+
if (org.description) markdown += `- **Description**: ${org.description.substring(0, 200)}
|
|
617
|
+
`;
|
|
618
|
+
markdown += `- **Datasets**: ${org.package_count || 0}
|
|
619
|
+
`;
|
|
620
|
+
markdown += `- **Created**: ${formatDate(org.created)}
|
|
621
|
+
`;
|
|
622
|
+
markdown += `- **Link**: ${getOrganizationViewUrl(params.server_url, org)}
|
|
623
|
+
|
|
624
|
+
`;
|
|
625
|
+
}
|
|
626
|
+
} else {
|
|
627
|
+
markdown += result.map((name) => `- ${name}`).join("\n");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return {
|
|
631
|
+
content: [{ type: "text", text: truncateText(markdown) }]
|
|
632
|
+
};
|
|
633
|
+
} catch (error) {
|
|
634
|
+
return {
|
|
635
|
+
content: [{
|
|
636
|
+
type: "text",
|
|
637
|
+
text: `Error listing organizations: ${error instanceof Error ? error.message : String(error)}`
|
|
638
|
+
}],
|
|
639
|
+
isError: true
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
);
|
|
644
|
+
server2.registerTool(
|
|
645
|
+
"ckan_organization_show",
|
|
646
|
+
{
|
|
647
|
+
title: "Show CKAN Organization Details",
|
|
648
|
+
description: `Get details of a specific organization.
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
- server_url (string): Base URL of CKAN server
|
|
652
|
+
- id (string): Organization ID or name
|
|
653
|
+
- include_datasets (boolean): Include list of datasets (default: true)
|
|
654
|
+
- include_users (boolean): Include list of users (default: false)
|
|
655
|
+
- response_format ('markdown' | 'json'): Output format
|
|
656
|
+
|
|
657
|
+
Returns:
|
|
658
|
+
Organization details with optional datasets and users`,
|
|
659
|
+
inputSchema: z3.object({
|
|
660
|
+
server_url: z3.string().url(),
|
|
661
|
+
id: z3.string().min(1),
|
|
662
|
+
include_datasets: z3.boolean().optional().default(true),
|
|
663
|
+
include_users: z3.boolean().optional().default(false),
|
|
664
|
+
response_format: ResponseFormatSchema
|
|
665
|
+
}).strict(),
|
|
666
|
+
annotations: {
|
|
667
|
+
readOnlyHint: true,
|
|
668
|
+
destructiveHint: false,
|
|
669
|
+
idempotentHint: true,
|
|
670
|
+
openWorldHint: false
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
async (params) => {
|
|
674
|
+
try {
|
|
675
|
+
const result = await makeCkanRequest(
|
|
676
|
+
params.server_url,
|
|
677
|
+
"organization_show",
|
|
678
|
+
{
|
|
679
|
+
id: params.id,
|
|
680
|
+
include_datasets: params.include_datasets,
|
|
681
|
+
include_users: params.include_users
|
|
682
|
+
}
|
|
683
|
+
);
|
|
684
|
+
if (params.response_format === "json" /* JSON */) {
|
|
685
|
+
return {
|
|
686
|
+
content: [{ type: "text", text: truncateText(JSON.stringify(result, null, 2)) }],
|
|
687
|
+
structuredContent: result
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
let markdown = `# Organization: ${result.title || result.name}
|
|
691
|
+
|
|
692
|
+
`;
|
|
693
|
+
markdown += `**Server**: ${params.server_url}
|
|
694
|
+
`;
|
|
695
|
+
markdown += `**Link**: ${getOrganizationViewUrl(params.server_url, result)}
|
|
696
|
+
|
|
697
|
+
`;
|
|
698
|
+
markdown += `## Details
|
|
699
|
+
|
|
700
|
+
`;
|
|
701
|
+
markdown += `- **ID**: \`${result.id}\`
|
|
702
|
+
`;
|
|
703
|
+
markdown += `- **Name**: \`${result.name}\`
|
|
704
|
+
`;
|
|
705
|
+
markdown += `- **Datasets**: ${result.package_count || 0}
|
|
706
|
+
`;
|
|
707
|
+
markdown += `- **Created**: ${formatDate(result.created)}
|
|
708
|
+
`;
|
|
709
|
+
markdown += `- **State**: ${result.state}
|
|
710
|
+
|
|
711
|
+
`;
|
|
712
|
+
if (result.description) {
|
|
713
|
+
markdown += `## Description
|
|
714
|
+
|
|
715
|
+
${result.description}
|
|
716
|
+
|
|
717
|
+
`;
|
|
718
|
+
}
|
|
719
|
+
if (result.packages && result.packages.length > 0) {
|
|
720
|
+
markdown += `## Datasets (${result.packages.length})
|
|
721
|
+
|
|
722
|
+
`;
|
|
723
|
+
for (const pkg of result.packages.slice(0, 20)) {
|
|
724
|
+
markdown += `- **${pkg.title || pkg.name}** (\`${pkg.name}\`)
|
|
725
|
+
`;
|
|
726
|
+
}
|
|
727
|
+
if (result.packages.length > 20) {
|
|
728
|
+
markdown += `
|
|
729
|
+
... and ${result.packages.length - 20} more datasets
|
|
730
|
+
`;
|
|
731
|
+
}
|
|
732
|
+
markdown += "\n";
|
|
733
|
+
}
|
|
734
|
+
if (result.users && result.users.length > 0) {
|
|
735
|
+
markdown += `## Users (${result.users.length})
|
|
736
|
+
|
|
737
|
+
`;
|
|
738
|
+
for (const user of result.users) {
|
|
739
|
+
markdown += `- **${user.name}** (${user.capacity})
|
|
740
|
+
`;
|
|
741
|
+
}
|
|
742
|
+
markdown += "\n";
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
content: [{ type: "text", text: truncateText(markdown) }]
|
|
746
|
+
};
|
|
747
|
+
} catch (error) {
|
|
748
|
+
return {
|
|
749
|
+
content: [{
|
|
750
|
+
type: "text",
|
|
751
|
+
text: `Error fetching organization: ${error instanceof Error ? error.message : String(error)}`
|
|
752
|
+
}],
|
|
753
|
+
isError: true
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
);
|
|
758
|
+
server2.registerTool(
|
|
759
|
+
"ckan_organization_search",
|
|
760
|
+
{
|
|
761
|
+
title: "Search CKAN Organizations by Name",
|
|
762
|
+
description: `Search for organizations by name pattern.
|
|
763
|
+
|
|
764
|
+
This tool provides a simpler interface than package_search for finding organizations.
|
|
765
|
+
Wildcards are automatically added around the search pattern.
|
|
766
|
+
|
|
767
|
+
Args:
|
|
768
|
+
- server_url (string): Base URL of CKAN server
|
|
769
|
+
- pattern (string): Search pattern (e.g., "toscana", "salute")
|
|
770
|
+
- response_format ('markdown' | 'json'): Output format
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
List of matching organizations with dataset counts
|
|
774
|
+
|
|
775
|
+
Examples:
|
|
776
|
+
- { server_url: "https://www.dati.gov.it/opendata", pattern: "toscana" }
|
|
777
|
+
- { server_url: "https://catalog.data.gov", pattern: "health" }`,
|
|
778
|
+
inputSchema: z3.object({
|
|
779
|
+
server_url: z3.string().url(),
|
|
780
|
+
pattern: z3.string().min(1).describe("Search pattern (wildcards added automatically)"),
|
|
781
|
+
response_format: ResponseFormatSchema
|
|
782
|
+
}).strict(),
|
|
783
|
+
annotations: {
|
|
784
|
+
readOnlyHint: true,
|
|
785
|
+
destructiveHint: false,
|
|
786
|
+
idempotentHint: true,
|
|
787
|
+
openWorldHint: true
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
async (params) => {
|
|
791
|
+
try {
|
|
792
|
+
const query = `organization:*${params.pattern}*`;
|
|
793
|
+
const result = await makeCkanRequest(
|
|
794
|
+
params.server_url,
|
|
795
|
+
"package_search",
|
|
796
|
+
{
|
|
797
|
+
q: query,
|
|
798
|
+
rows: 0,
|
|
799
|
+
"facet.field": JSON.stringify(["organization"]),
|
|
800
|
+
"facet.limit": 500
|
|
801
|
+
}
|
|
802
|
+
);
|
|
803
|
+
const orgFacets = result.search_facets?.organization?.items || [];
|
|
804
|
+
const totalDatasets = result.count || 0;
|
|
805
|
+
if (params.response_format === "json" /* JSON */) {
|
|
806
|
+
const jsonResult = {
|
|
807
|
+
count: orgFacets.length,
|
|
808
|
+
total_datasets: totalDatasets,
|
|
809
|
+
organizations: orgFacets.map((item) => ({
|
|
810
|
+
name: item.name,
|
|
811
|
+
display_name: item.display_name,
|
|
812
|
+
dataset_count: item.count
|
|
813
|
+
}))
|
|
814
|
+
};
|
|
815
|
+
return {
|
|
816
|
+
content: [{ type: "text", text: truncateText(JSON.stringify(jsonResult, null, 2)) }],
|
|
817
|
+
structuredContent: jsonResult
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
let markdown = `# CKAN Organization Search Results
|
|
821
|
+
|
|
822
|
+
`;
|
|
823
|
+
markdown += `**Server**: ${params.server_url}
|
|
824
|
+
`;
|
|
825
|
+
markdown += `**Pattern**: "${params.pattern}"
|
|
826
|
+
`;
|
|
827
|
+
markdown += `**Organizations Found**: ${orgFacets.length}
|
|
828
|
+
`;
|
|
829
|
+
markdown += `**Total Datasets**: ${totalDatasets}
|
|
830
|
+
|
|
831
|
+
`;
|
|
832
|
+
if (orgFacets.length === 0) {
|
|
833
|
+
markdown += `No organizations found matching pattern "${params.pattern}".
|
|
834
|
+
`;
|
|
835
|
+
} else {
|
|
836
|
+
markdown += `## Matching Organizations
|
|
837
|
+
|
|
838
|
+
`;
|
|
839
|
+
markdown += `| Organization | Datasets |
|
|
840
|
+
`;
|
|
841
|
+
markdown += `|--------------|----------|
|
|
842
|
+
`;
|
|
843
|
+
for (const org of orgFacets) {
|
|
844
|
+
markdown += `| ${org.display_name || org.name} | ${org.count} |
|
|
845
|
+
`;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
return {
|
|
849
|
+
content: [{ type: "text", text: truncateText(markdown) }]
|
|
850
|
+
};
|
|
851
|
+
} catch (error) {
|
|
852
|
+
return {
|
|
853
|
+
content: [{
|
|
854
|
+
type: "text",
|
|
855
|
+
text: `Error searching organizations: ${error instanceof Error ? error.message : String(error)}`
|
|
856
|
+
}],
|
|
857
|
+
isError: true
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// src/tools/datastore.ts
|
|
865
|
+
import { z as z4 } from "zod";
|
|
866
|
+
function registerDatastoreTools(server2) {
|
|
867
|
+
server2.registerTool(
|
|
868
|
+
"ckan_datastore_search",
|
|
869
|
+
{
|
|
870
|
+
title: "Search CKAN DataStore",
|
|
871
|
+
description: `Query data from a CKAN DataStore resource.
|
|
872
|
+
|
|
873
|
+
The DataStore allows SQL-like queries on tabular data. Not all resources have DataStore enabled.
|
|
874
|
+
|
|
875
|
+
Args:
|
|
876
|
+
- server_url (string): Base URL of CKAN server
|
|
877
|
+
- resource_id (string): ID of the DataStore resource
|
|
878
|
+
- q (string): Full-text search query (optional)
|
|
879
|
+
- filters (object): Key-value filters (e.g., { "anno": 2023 })
|
|
880
|
+
- limit (number): Max rows to return (default: 100, max: 32000)
|
|
881
|
+
- offset (number): Pagination offset (default: 0)
|
|
882
|
+
- fields (array): Specific fields to return (optional)
|
|
883
|
+
- sort (string): Sort field with direction (e.g., "anno desc")
|
|
884
|
+
- distinct (boolean): Return distinct values (default: false)
|
|
885
|
+
- response_format ('markdown' | 'json'): Output format
|
|
886
|
+
|
|
887
|
+
Returns:
|
|
888
|
+
DataStore records matching query
|
|
889
|
+
|
|
890
|
+
Examples:
|
|
891
|
+
- { server_url: "...", resource_id: "abc-123", limit: 50 }
|
|
892
|
+
- { server_url: "...", resource_id: "...", filters: { "regione": "Sicilia" } }
|
|
893
|
+
- { server_url: "...", resource_id: "...", sort: "anno desc", limit: 100 }`,
|
|
894
|
+
inputSchema: z4.object({
|
|
895
|
+
server_url: z4.string().url(),
|
|
896
|
+
resource_id: z4.string().min(1),
|
|
897
|
+
q: z4.string().optional(),
|
|
898
|
+
filters: z4.record(z4.any()).optional(),
|
|
899
|
+
limit: z4.number().int().min(1).max(32e3).optional().default(100),
|
|
900
|
+
offset: z4.number().int().min(0).optional().default(0),
|
|
901
|
+
fields: z4.array(z4.string()).optional(),
|
|
902
|
+
sort: z4.string().optional(),
|
|
903
|
+
distinct: z4.boolean().optional().default(false),
|
|
904
|
+
response_format: ResponseFormatSchema
|
|
905
|
+
}).strict(),
|
|
906
|
+
annotations: {
|
|
907
|
+
readOnlyHint: true,
|
|
908
|
+
destructiveHint: false,
|
|
909
|
+
idempotentHint: true,
|
|
910
|
+
openWorldHint: false
|
|
911
|
+
}
|
|
912
|
+
},
|
|
913
|
+
async (params) => {
|
|
914
|
+
try {
|
|
915
|
+
const apiParams = {
|
|
916
|
+
resource_id: params.resource_id,
|
|
917
|
+
limit: params.limit,
|
|
918
|
+
offset: params.offset,
|
|
919
|
+
distinct: params.distinct
|
|
920
|
+
};
|
|
921
|
+
if (params.q) apiParams.q = params.q;
|
|
922
|
+
if (params.filters) apiParams.filters = JSON.stringify(params.filters);
|
|
923
|
+
if (params.fields) apiParams.fields = params.fields.join(",");
|
|
924
|
+
if (params.sort) apiParams.sort = params.sort;
|
|
925
|
+
const result = await makeCkanRequest(
|
|
926
|
+
params.server_url,
|
|
927
|
+
"datastore_search",
|
|
928
|
+
apiParams
|
|
929
|
+
);
|
|
930
|
+
if (params.response_format === "json" /* JSON */) {
|
|
931
|
+
return {
|
|
932
|
+
content: [{ type: "text", text: truncateText(JSON.stringify(result, null, 2)) }],
|
|
933
|
+
structuredContent: result
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
let markdown = `# DataStore Query Results
|
|
937
|
+
|
|
938
|
+
`;
|
|
939
|
+
markdown += `**Server**: ${params.server_url}
|
|
940
|
+
`;
|
|
941
|
+
markdown += `**Resource ID**: \`${params.resource_id}\`
|
|
942
|
+
`;
|
|
943
|
+
markdown += `**Total Records**: ${result.total || 0}
|
|
944
|
+
`;
|
|
945
|
+
markdown += `**Returned**: ${result.records ? result.records.length : 0} records
|
|
946
|
+
|
|
947
|
+
`;
|
|
948
|
+
if (result.fields && result.fields.length > 0) {
|
|
949
|
+
markdown += `## Fields
|
|
950
|
+
|
|
951
|
+
`;
|
|
952
|
+
markdown += result.fields.map((f) => `- **${f.id}** (${f.type})`).join("\n") + "\n\n";
|
|
953
|
+
}
|
|
954
|
+
if (result.records && result.records.length > 0) {
|
|
955
|
+
markdown += `## Records
|
|
956
|
+
|
|
957
|
+
`;
|
|
958
|
+
const fields = result.fields.map((f) => f.id);
|
|
959
|
+
const displayFields = fields.slice(0, 8);
|
|
960
|
+
markdown += `| ${displayFields.join(" | ")} |
|
|
961
|
+
`;
|
|
962
|
+
markdown += `| ${displayFields.map(() => "---").join(" | ")} |
|
|
963
|
+
`;
|
|
964
|
+
for (const record of result.records.slice(0, 50)) {
|
|
965
|
+
const values = displayFields.map((field) => {
|
|
966
|
+
const val = record[field];
|
|
967
|
+
if (val === null || val === void 0) return "-";
|
|
968
|
+
const str = String(val);
|
|
969
|
+
return str.length > 50 ? str.substring(0, 47) + "..." : str;
|
|
970
|
+
});
|
|
971
|
+
markdown += `| ${values.join(" | ")} |
|
|
972
|
+
`;
|
|
973
|
+
}
|
|
974
|
+
if (result.records.length > 50) {
|
|
975
|
+
markdown += `
|
|
976
|
+
... and ${result.records.length - 50} more records
|
|
977
|
+
`;
|
|
978
|
+
}
|
|
979
|
+
markdown += "\n";
|
|
980
|
+
}
|
|
981
|
+
if (result.total && result.total > params.offset + (result.records?.length || 0)) {
|
|
982
|
+
const nextOffset = params.offset + params.limit;
|
|
983
|
+
markdown += `**More results available**: Use \`offset: ${nextOffset}\` for next page.
|
|
984
|
+
`;
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
content: [{ type: "text", text: truncateText(markdown) }]
|
|
988
|
+
};
|
|
989
|
+
} catch (error) {
|
|
990
|
+
return {
|
|
991
|
+
content: [{
|
|
992
|
+
type: "text",
|
|
993
|
+
text: `Error querying DataStore: ${error instanceof Error ? error.message : String(error)}`
|
|
994
|
+
}],
|
|
995
|
+
isError: true
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// src/tools/status.ts
|
|
1003
|
+
import { z as z5 } from "zod";
|
|
1004
|
+
function registerStatusTools(server2) {
|
|
1005
|
+
server2.registerTool(
|
|
1006
|
+
"ckan_status_show",
|
|
1007
|
+
{
|
|
1008
|
+
title: "Check CKAN Server Status",
|
|
1009
|
+
description: `Check if a CKAN server is available and get version information.
|
|
1010
|
+
|
|
1011
|
+
Useful to verify server accessibility before making other requests.
|
|
1012
|
+
|
|
1013
|
+
Args:
|
|
1014
|
+
- server_url (string): Base URL of CKAN server
|
|
1015
|
+
|
|
1016
|
+
Returns:
|
|
1017
|
+
Server status and version information`,
|
|
1018
|
+
inputSchema: z5.object({
|
|
1019
|
+
server_url: z5.string().url().describe("Base URL of the CKAN server")
|
|
1020
|
+
}).strict(),
|
|
1021
|
+
annotations: {
|
|
1022
|
+
readOnlyHint: true,
|
|
1023
|
+
destructiveHint: false,
|
|
1024
|
+
idempotentHint: true,
|
|
1025
|
+
openWorldHint: false
|
|
1026
|
+
}
|
|
1027
|
+
},
|
|
1028
|
+
async (params) => {
|
|
1029
|
+
try {
|
|
1030
|
+
const result = await makeCkanRequest(
|
|
1031
|
+
params.server_url,
|
|
1032
|
+
"status_show",
|
|
1033
|
+
{}
|
|
1034
|
+
);
|
|
1035
|
+
const markdown = `# CKAN Server Status
|
|
1036
|
+
|
|
1037
|
+
**Server**: ${params.server_url}
|
|
1038
|
+
**Status**: \u2705 Online
|
|
1039
|
+
**CKAN Version**: ${result.ckan_version || "Unknown"}
|
|
1040
|
+
**Site Title**: ${result.site_title || "N/A"}
|
|
1041
|
+
**Site URL**: ${result.site_url || "N/A"}
|
|
1042
|
+
`;
|
|
1043
|
+
return {
|
|
1044
|
+
content: [{ type: "text", text: markdown }],
|
|
1045
|
+
structuredContent: result
|
|
1046
|
+
};
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
return {
|
|
1049
|
+
content: [{
|
|
1050
|
+
type: "text",
|
|
1051
|
+
text: `Server appears to be offline or not a valid CKAN instance:
|
|
1052
|
+
${error instanceof Error ? error.message : String(error)}`
|
|
1053
|
+
}],
|
|
1054
|
+
isError: true
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// src/resources/dataset.ts
|
|
1062
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1063
|
+
|
|
1064
|
+
// src/resources/uri.ts
|
|
1065
|
+
function parseCkanUri(uri) {
|
|
1066
|
+
const hostname = uri.hostname;
|
|
1067
|
+
if (!hostname) {
|
|
1068
|
+
throw new Error("Invalid ckan:// URI: missing server hostname");
|
|
1069
|
+
}
|
|
1070
|
+
const pathParts = uri.pathname.split("/").filter((p) => p.length > 0);
|
|
1071
|
+
if (pathParts.length < 2) {
|
|
1072
|
+
throw new Error(
|
|
1073
|
+
`Invalid ckan:// URI: expected /{type}/{id}, got ${uri.pathname}`
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
const [type, ...idParts] = pathParts;
|
|
1077
|
+
const id = idParts.join("/");
|
|
1078
|
+
if (!type || !id) {
|
|
1079
|
+
throw new Error("Invalid ckan:// URI: missing type or id");
|
|
1080
|
+
}
|
|
1081
|
+
const server2 = `https://${hostname}`;
|
|
1082
|
+
return { server: server2, type, id };
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// src/resources/dataset.ts
|
|
1086
|
+
function registerDatasetResource(server2) {
|
|
1087
|
+
server2.registerResource(
|
|
1088
|
+
"ckan-dataset",
|
|
1089
|
+
new ResourceTemplate("ckan://{server}/dataset/{id}", { list: void 0 }),
|
|
1090
|
+
{
|
|
1091
|
+
title: "CKAN Dataset",
|
|
1092
|
+
description: "Access dataset metadata from any CKAN server. URI format: ckan://{server}/dataset/{id}",
|
|
1093
|
+
mimeType: "application/json"
|
|
1094
|
+
},
|
|
1095
|
+
async (uri, variables) => {
|
|
1096
|
+
try {
|
|
1097
|
+
const { server: serverUrl } = parseCkanUri(uri);
|
|
1098
|
+
const id = variables.id;
|
|
1099
|
+
const result = await makeCkanRequest(serverUrl, "package_show", {
|
|
1100
|
+
id
|
|
1101
|
+
});
|
|
1102
|
+
const content = truncateText(JSON.stringify(result, null, 2));
|
|
1103
|
+
return {
|
|
1104
|
+
contents: [
|
|
1105
|
+
{
|
|
1106
|
+
uri: uri.href,
|
|
1107
|
+
mimeType: "application/json",
|
|
1108
|
+
text: content
|
|
1109
|
+
}
|
|
1110
|
+
]
|
|
1111
|
+
};
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1114
|
+
return {
|
|
1115
|
+
contents: [
|
|
1116
|
+
{
|
|
1117
|
+
uri: uri.href,
|
|
1118
|
+
mimeType: "text/plain",
|
|
1119
|
+
text: `Error fetching dataset: ${errorMessage}`
|
|
1120
|
+
}
|
|
1121
|
+
]
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// src/resources/resource.ts
|
|
1129
|
+
import { ResourceTemplate as ResourceTemplate2 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1130
|
+
function registerResourceResource(server2) {
|
|
1131
|
+
server2.registerResource(
|
|
1132
|
+
"ckan-resource",
|
|
1133
|
+
new ResourceTemplate2("ckan://{server}/resource/{id}", { list: void 0 }),
|
|
1134
|
+
{
|
|
1135
|
+
title: "CKAN Resource",
|
|
1136
|
+
description: "Access resource metadata and download URL from any CKAN server. URI format: ckan://{server}/resource/{id}",
|
|
1137
|
+
mimeType: "application/json"
|
|
1138
|
+
},
|
|
1139
|
+
async (uri, variables) => {
|
|
1140
|
+
try {
|
|
1141
|
+
const { server: serverUrl } = parseCkanUri(uri);
|
|
1142
|
+
const id = variables.id;
|
|
1143
|
+
const result = await makeCkanRequest(serverUrl, "resource_show", {
|
|
1144
|
+
id
|
|
1145
|
+
});
|
|
1146
|
+
const content = truncateText(JSON.stringify(result, null, 2));
|
|
1147
|
+
return {
|
|
1148
|
+
contents: [
|
|
1149
|
+
{
|
|
1150
|
+
uri: uri.href,
|
|
1151
|
+
mimeType: "application/json",
|
|
1152
|
+
text: content
|
|
1153
|
+
}
|
|
1154
|
+
]
|
|
1155
|
+
};
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1158
|
+
return {
|
|
1159
|
+
contents: [
|
|
1160
|
+
{
|
|
1161
|
+
uri: uri.href,
|
|
1162
|
+
mimeType: "text/plain",
|
|
1163
|
+
text: `Error fetching resource: ${errorMessage}`
|
|
1164
|
+
}
|
|
1165
|
+
]
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// src/resources/organization.ts
|
|
1173
|
+
import { ResourceTemplate as ResourceTemplate3 } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1174
|
+
function registerOrganizationResource(server2) {
|
|
1175
|
+
server2.registerResource(
|
|
1176
|
+
"ckan-organization",
|
|
1177
|
+
new ResourceTemplate3("ckan://{server}/organization/{name}", {
|
|
1178
|
+
list: void 0
|
|
1179
|
+
}),
|
|
1180
|
+
{
|
|
1181
|
+
title: "CKAN Organization",
|
|
1182
|
+
description: "Access organization metadata from any CKAN server. URI format: ckan://{server}/organization/{name}",
|
|
1183
|
+
mimeType: "application/json"
|
|
1184
|
+
},
|
|
1185
|
+
async (uri, variables) => {
|
|
1186
|
+
try {
|
|
1187
|
+
const { server: serverUrl } = parseCkanUri(uri);
|
|
1188
|
+
const name = variables.name;
|
|
1189
|
+
const result = await makeCkanRequest(
|
|
1190
|
+
serverUrl,
|
|
1191
|
+
"organization_show",
|
|
1192
|
+
{
|
|
1193
|
+
id: name,
|
|
1194
|
+
include_datasets: false
|
|
1195
|
+
}
|
|
1196
|
+
);
|
|
1197
|
+
const content = truncateText(JSON.stringify(result, null, 2));
|
|
1198
|
+
return {
|
|
1199
|
+
contents: [
|
|
1200
|
+
{
|
|
1201
|
+
uri: uri.href,
|
|
1202
|
+
mimeType: "application/json",
|
|
1203
|
+
text: content
|
|
1204
|
+
}
|
|
1205
|
+
]
|
|
1206
|
+
};
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1209
|
+
return {
|
|
1210
|
+
contents: [
|
|
1211
|
+
{
|
|
1212
|
+
uri: uri.href,
|
|
1213
|
+
mimeType: "text/plain",
|
|
1214
|
+
text: `Error fetching organization: ${errorMessage}`
|
|
1215
|
+
}
|
|
1216
|
+
]
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// src/resources/index.ts
|
|
1224
|
+
function registerAllResources(server2) {
|
|
1225
|
+
registerDatasetResource(server2);
|
|
1226
|
+
registerResourceResource(server2);
|
|
1227
|
+
registerOrganizationResource(server2);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// src/transport/stdio.ts
|
|
1231
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
1232
|
+
async function runStdio(server2) {
|
|
1233
|
+
const transport2 = new StdioServerTransport();
|
|
1234
|
+
await server2.connect(transport2);
|
|
1235
|
+
console.error("CKAN MCP server running on stdio");
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// src/transport/http.ts
|
|
1239
|
+
import express from "express";
|
|
1240
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
1241
|
+
async function runHTTP(server2) {
|
|
1242
|
+
const app = express();
|
|
1243
|
+
app.use(express.json());
|
|
1244
|
+
app.post("/mcp", async (req, res) => {
|
|
1245
|
+
const transport2 = new StreamableHTTPServerTransport({
|
|
1246
|
+
sessionIdGenerator: void 0,
|
|
1247
|
+
enableJsonResponse: true
|
|
1248
|
+
});
|
|
1249
|
+
res.on("close", () => transport2.close());
|
|
1250
|
+
await server2.connect(transport2);
|
|
1251
|
+
await transport2.handleRequest(req, res, req.body);
|
|
1252
|
+
});
|
|
1253
|
+
const port = parseInt(process.env.PORT || "3000");
|
|
1254
|
+
app.listen(port, () => {
|
|
1255
|
+
console.error(`CKAN MCP server running on http://localhost:${port}/mcp`);
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// src/index.ts
|
|
1260
|
+
var server = createServer();
|
|
1261
|
+
registerPackageTools(server);
|
|
1262
|
+
registerOrganizationTools(server);
|
|
1263
|
+
registerDatastoreTools(server);
|
|
1264
|
+
registerStatusTools(server);
|
|
1265
|
+
registerAllResources(server);
|
|
1266
|
+
var transport = process.env.TRANSPORT || "stdio";
|
|
1267
|
+
if (transport === "http") {
|
|
1268
|
+
runHTTP(server).catch((error) => {
|
|
1269
|
+
console.error("Server error:", error);
|
|
1270
|
+
process.exit(1);
|
|
1271
|
+
});
|
|
1272
|
+
} else {
|
|
1273
|
+
runStdio(server).catch((error) => {
|
|
1274
|
+
console.error("Server error:", error);
|
|
1275
|
+
process.exit(1);
|
|
1276
|
+
});
|
|
1277
|
+
}
|