@aborruso/ckan-mcp-server 0.4.5 → 0.4.6

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 (27) hide show
  1. package/EXAMPLES.md +9 -0
  2. package/LOG.md +7 -0
  3. package/README.md +12 -1
  4. package/dist/index.js +363 -3
  5. package/dist/worker.js +2 -2
  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/add-ckan-find-relevant-datasets/proposal.md +17 -0
  19. package/openspec/changes/add-ckan-find-relevant-datasets/specs/ckan-insights/spec.md +7 -0
  20. package/openspec/changes/add-ckan-find-relevant-datasets/tasks.md +6 -0
  21. package/openspec/specs/ckan-insights/spec.md +12 -0
  22. package/openspec/specs/cloudflare-deployment/spec.md +344 -0
  23. package/package.json +1 -1
  24. /package/openspec/changes/{add-cloudflare-workers → archive/2026-01-10-add-cloudflare-workers}/design.md +0 -0
  25. /package/openspec/changes/{add-cloudflare-workers → archive/2026-01-10-add-cloudflare-workers}/proposal.md +0 -0
  26. /package/openspec/changes/{add-cloudflare-workers → archive/2026-01-10-add-cloudflare-workers}/specs/cloudflare-deployment/spec.md +0 -0
  27. /package/openspec/changes/{add-cloudflare-workers → archive/2026-01-10-add-cloudflare-workers}/tasks.md +0 -0
package/EXAMPLES.md CHANGED
@@ -20,6 +20,15 @@ ckan_package_search({
20
20
  })
21
21
  ```
22
22
 
23
+ ### Find relevant datasets
24
+ ```typescript
25
+ ckan_find_relevant_datasets({
26
+ server_url: "https://demo.ckan.org",
27
+ query: "open data transport",
28
+ limit: 5
29
+ })
30
+ ```
31
+
23
32
  ## Italy Examples - dati.gov.it
24
33
 
25
34
  ### Search recent datasets
package/LOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## 2026-01-10
4
4
 
5
+ ### Version 0.4.6 - Relevance ranking
6
+ - **Tool**: Added `ckan_find_relevant_datasets`
7
+ - **Docs**: Updated README/EXAMPLES
8
+ - **Tests**: Added relevance scoring checks
9
+
10
+ ## 2026-01-10
11
+
5
12
  ### Version 0.4.5 - Health version
6
13
  - **Workers**: /health version/tools updated
7
14
 
package/README.md CHANGED
@@ -56,7 +56,7 @@ TRANSPORT=http PORT=3000 npm start
56
56
 
57
57
  The server will be available at `http://localhost:3000/mcp`
58
58
 
59
- ## Deployment Options
59
+ ## Usage Options
60
60
 
61
61
  ### Option 1: Local Installation (stdio mode)
62
62
 
@@ -173,6 +173,7 @@ Use the public Cloudflare Workers deployment (no local installation required):
173
173
  ### Search and Discovery
174
174
 
175
175
  - **ckan_package_search**: Search datasets with Solr queries
176
+ - **ckan_find_relevant_datasets**: Rank datasets by relevance score
176
177
  - **ckan_package_show**: Complete details of a dataset
177
178
  - **ckan_package_list**: List all datasets
178
179
  - **ckan_tag_list**: List tags with counts
@@ -225,6 +226,16 @@ ckan_package_search({
225
226
  })
226
227
  ```
227
228
 
229
+ ### Rank datasets by relevance
230
+
231
+ ```typescript
232
+ ckan_find_relevant_datasets({
233
+ server_url: "https://www.dati.gov.it/opendata",
234
+ query: "mobilità urbana",
235
+ limit: 5
236
+ })
237
+ ```
238
+
228
239
  ### Filter by organization
229
240
 
230
241
  ```typescript
package/dist/index.js CHANGED
@@ -126,6 +126,91 @@ function getOrganizationViewUrl(serverUrl, org) {
126
126
  }
127
127
 
128
128
  // src/tools/package.ts
129
+ var DEFAULT_RELEVANCE_WEIGHTS = {
130
+ title: 4,
131
+ notes: 2,
132
+ tags: 3,
133
+ organization: 1
134
+ };
135
+ var QUERY_STOPWORDS = /* @__PURE__ */ new Set([
136
+ "a",
137
+ "an",
138
+ "the",
139
+ "and",
140
+ "or",
141
+ "but",
142
+ "in",
143
+ "on",
144
+ "at",
145
+ "to",
146
+ "for",
147
+ "of",
148
+ "with",
149
+ "by",
150
+ "from",
151
+ "as",
152
+ "is",
153
+ "was",
154
+ "are",
155
+ "were",
156
+ "be",
157
+ "been",
158
+ "being",
159
+ "have",
160
+ "has",
161
+ "had",
162
+ "do",
163
+ "does",
164
+ "did",
165
+ "will",
166
+ "would",
167
+ "could",
168
+ "should",
169
+ "may",
170
+ "might",
171
+ "must",
172
+ "can",
173
+ "this",
174
+ "that",
175
+ "these",
176
+ "those"
177
+ ]);
178
+ var extractQueryTerms = (query) => {
179
+ const matches = query.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
180
+ const terms = matches.filter((term) => term.length > 1 && !QUERY_STOPWORDS.has(term));
181
+ return Array.from(new Set(terms));
182
+ };
183
+ var escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
184
+ var textMatchesTerms = (text, terms) => {
185
+ if (!text || terms.length === 0) return false;
186
+ const normalized = text.toLowerCase().replace(/_/g, " ");
187
+ return terms.some((term) => new RegExp(`\\b${escapeRegExp(term)}\\b`, "i").test(normalized));
188
+ };
189
+ var scoreTextField = (text, terms, weight) => {
190
+ return textMatchesTerms(text, terms) ? weight : 0;
191
+ };
192
+ var scoreDatasetRelevance = (query, dataset, weights = DEFAULT_RELEVANCE_WEIGHTS) => {
193
+ const terms = extractQueryTerms(query);
194
+ const titleText = dataset.title || dataset.name || "";
195
+ const notesText = dataset.notes || "";
196
+ const orgText = dataset.organization?.title || dataset.organization?.name || dataset.owner_org || "";
197
+ const breakdown = {
198
+ title: scoreTextField(titleText, terms, weights.title),
199
+ notes: scoreTextField(notesText, terms, weights.notes),
200
+ tags: 0,
201
+ organization: scoreTextField(orgText, terms, weights.organization),
202
+ total: 0
203
+ };
204
+ if (Array.isArray(dataset.tags) && dataset.tags.length > 0 && terms.length > 0) {
205
+ const tagMatch = dataset.tags.some((tag) => {
206
+ const tagValue = typeof tag === "string" ? tag : tag?.name;
207
+ return textMatchesTerms(tagValue, terms);
208
+ });
209
+ breakdown.tags = tagMatch ? weights.tags : 0;
210
+ }
211
+ breakdown.total = breakdown.title + breakdown.notes + breakdown.tags + breakdown.organization;
212
+ return { total: breakdown.total, breakdown, terms };
213
+ };
129
214
  function registerPackageTools(server2) {
130
215
  server2.registerTool(
131
216
  "ckan_package_search",
@@ -137,7 +222,7 @@ Supports full Solr search capabilities including filters, facets, and sorting.
137
222
  Use this to discover datasets matching specific criteria.
138
223
 
139
224
  Args:
140
- - server_url (string): Base URL of CKAN server (e.g., "https://dati.gov.it")
225
+ - server_url (string): Base URL of CKAN server (e.g., "https://dati.gov.it/opendata")
141
226
  - q (string): Search query using Solr syntax (default: "*:*" for all)
142
227
  - fq (string): Filter query (e.g., "organization:comune-palermo")
143
228
  - rows (number): Number of results to return (default: 10, max: 1000)
@@ -335,6 +420,177 @@ ${params.fq ? `**Filter**: ${params.fq}
335
420
  }
336
421
  }
337
422
  );
423
+ server2.registerTool(
424
+ "ckan_find_relevant_datasets",
425
+ {
426
+ title: "Find Relevant CKAN Datasets",
427
+ description: `Find and rank datasets by relevance to a query using weighted fields.
428
+
429
+ Uses package_search for discovery and applies a local scoring model.
430
+
431
+ Args:
432
+ - server_url (string): Base URL of CKAN server (e.g., "https://dati.gov.it/opendata")
433
+ - query (string): Search query text
434
+ - limit (number): Number of datasets to return (default: 10)
435
+ - weights (object): Optional weights for title/notes/tags/organization
436
+ - response_format ('markdown' | 'json'): Output format
437
+
438
+ Returns:
439
+ Ranked datasets with relevance scores and breakdowns
440
+
441
+ Examples:
442
+ - { server_url: "https://dati.gov.it/opendata", query: "mobilit\xE0" }
443
+ - { server_url: "...", query: "trasporti", limit: 5, weights: { title: 5, notes: 2 } }`,
444
+ inputSchema: z2.object({
445
+ server_url: z2.string().url().describe("Base URL of the CKAN server (e.g., https://dati.gov.it/opendata)"),
446
+ query: z2.string().min(2).describe("Search query text"),
447
+ limit: z2.number().int().min(1).max(50).optional().default(10).describe("Number of datasets to return"),
448
+ weights: z2.object({
449
+ title: z2.number().min(0).optional(),
450
+ notes: z2.number().min(0).optional(),
451
+ tags: z2.number().min(0).optional(),
452
+ organization: z2.number().min(0).optional()
453
+ }).optional().describe("Optional weights per field"),
454
+ response_format: ResponseFormatSchema
455
+ }).strict(),
456
+ annotations: {
457
+ readOnlyHint: true,
458
+ destructiveHint: false,
459
+ idempotentHint: true,
460
+ openWorldHint: true
461
+ }
462
+ },
463
+ async (params) => {
464
+ try {
465
+ const weights = {
466
+ ...DEFAULT_RELEVANCE_WEIGHTS,
467
+ ...params.weights ?? {}
468
+ };
469
+ const rows = Math.min(Math.max(params.limit * 5, params.limit), 100);
470
+ const searchResult = await makeCkanRequest(
471
+ params.server_url,
472
+ "package_search",
473
+ {
474
+ q: params.query,
475
+ rows,
476
+ start: 0
477
+ }
478
+ );
479
+ const scored = (searchResult.results || []).map((dataset) => {
480
+ const { total, breakdown } = scoreDatasetRelevance(
481
+ params.query,
482
+ dataset,
483
+ weights
484
+ );
485
+ return {
486
+ dataset,
487
+ score: total,
488
+ breakdown
489
+ };
490
+ });
491
+ scored.sort((a, b) => b.score - a.score);
492
+ const top = scored.slice(0, params.limit).map((item) => {
493
+ const dataset = item.dataset;
494
+ return {
495
+ id: dataset.id,
496
+ name: dataset.name,
497
+ title: dataset.title || dataset.name,
498
+ organization: dataset.organization?.title || dataset.organization?.name || dataset.owner_org,
499
+ tags: Array.isArray(dataset.tags) ? dataset.tags.map((tag) => tag.name) : [],
500
+ metadata_modified: dataset.metadata_modified,
501
+ score: item.score,
502
+ breakdown: item.breakdown
503
+ };
504
+ });
505
+ const payload = {
506
+ query: params.query,
507
+ terms: extractQueryTerms(params.query),
508
+ weights,
509
+ total_results: searchResult.count ?? 0,
510
+ returned: top.length,
511
+ results: top
512
+ };
513
+ if (params.response_format === "json" /* JSON */) {
514
+ return {
515
+ content: [{ type: "text", text: truncateText(JSON.stringify(payload, null, 2)) }],
516
+ structuredContent: payload
517
+ };
518
+ }
519
+ let markdown = `# Relevant CKAN Datasets
520
+
521
+ `;
522
+ markdown += `**Server**: ${params.server_url}
523
+ `;
524
+ markdown += `**Query**: ${params.query}
525
+ `;
526
+ markdown += `**Terms**: ${payload.terms.length > 0 ? payload.terms.join(", ") : "n/a"}
527
+ `;
528
+ markdown += `**Total Results**: ${payload.total_results}
529
+ `;
530
+ markdown += `**Returned**: ${payload.returned}
531
+
532
+ `;
533
+ markdown += `## Weights
534
+
535
+ `;
536
+ markdown += `- **Title**: ${weights.title}
537
+ `;
538
+ markdown += `- **Notes**: ${weights.notes}
539
+ `;
540
+ markdown += `- **Tags**: ${weights.tags}
541
+ `;
542
+ markdown += `- **Organization**: ${weights.organization}
543
+
544
+ `;
545
+ if (top.length === 0) {
546
+ markdown += "No datasets matched the query terms.\n";
547
+ } else {
548
+ markdown += `## Results
549
+
550
+ `;
551
+ markdown += `| Rank | Dataset | Score | Title | Org | Tags |
552
+ `;
553
+ markdown += `| --- | --- | --- | --- | --- | --- |
554
+ `;
555
+ top.forEach((dataset, index) => {
556
+ const tags = dataset.tags.slice(0, 3).join(", ");
557
+ markdown += `| ${index + 1} | ${dataset.name} | ${dataset.score} | ${dataset.title} | ${dataset.organization || "-"} | ${tags || "-"} |
558
+ `;
559
+ });
560
+ markdown += `
561
+ ### Score Breakdown
562
+
563
+ `;
564
+ top.forEach((dataset, index) => {
565
+ markdown += `**${index + 1}. ${dataset.title}**
566
+ `;
567
+ markdown += `- Title: ${dataset.breakdown.title}
568
+ `;
569
+ markdown += `- Notes: ${dataset.breakdown.notes}
570
+ `;
571
+ markdown += `- Tags: ${dataset.breakdown.tags}
572
+ `;
573
+ markdown += `- Organization: ${dataset.breakdown.organization}
574
+ `;
575
+ markdown += `- Total: ${dataset.breakdown.total}
576
+
577
+ `;
578
+ });
579
+ }
580
+ return {
581
+ content: [{ type: "text", text: truncateText(markdown) }]
582
+ };
583
+ } catch (error) {
584
+ return {
585
+ content: [{
586
+ type: "text",
587
+ text: `Error ranking datasets: ${error instanceof Error ? error.message : String(error)}`
588
+ }],
589
+ isError: true
590
+ };
591
+ }
592
+ }
593
+ );
338
594
  server2.registerTool(
339
595
  "ckan_package_show",
340
596
  {
@@ -353,7 +609,7 @@ Returns:
353
609
  Complete dataset object with all metadata and resources
354
610
 
355
611
  Examples:
356
- - { server_url: "https://dati.gov.it", id: "dataset-name" }
612
+ - { server_url: "https://dati.gov.it/opendata", id: "dataset-name" }
357
613
  - { server_url: "...", id: "abc-123-def", include_tracking: true }`,
358
614
  inputSchema: z2.object({
359
615
  server_url: z2.string().url().describe("Base URL of the CKAN server"),
@@ -998,6 +1254,110 @@ Examples:
998
1254
  }
999
1255
  }
1000
1256
  );
1257
+ server2.registerTool(
1258
+ "ckan_datastore_search_sql",
1259
+ {
1260
+ title: "Search CKAN DataStore with SQL",
1261
+ description: `Run SQL queries on a CKAN DataStore resource.
1262
+
1263
+ This endpoint is only available on CKAN portals with DataStore enabled and SQL access exposed.
1264
+
1265
+ Args:
1266
+ - server_url (string): Base URL of CKAN server
1267
+ - sql (string): SQL query (e.g., SELECT * FROM "resource_id" LIMIT 10)
1268
+ - response_format ('markdown' | 'json'): Output format
1269
+
1270
+ Returns:
1271
+ SQL query results from DataStore
1272
+
1273
+ Examples:
1274
+ - { server_url: "...", sql: "SELECT * FROM "abc-123" LIMIT 10" }
1275
+ - { server_url: "...", sql: "SELECT COUNT(*) AS total FROM "abc-123"" }`,
1276
+ inputSchema: z4.object({
1277
+ server_url: z4.string().url(),
1278
+ sql: z4.string().min(1),
1279
+ response_format: ResponseFormatSchema
1280
+ }).strict(),
1281
+ annotations: {
1282
+ readOnlyHint: true,
1283
+ destructiveHint: false,
1284
+ idempotentHint: true,
1285
+ openWorldHint: false
1286
+ }
1287
+ },
1288
+ async (params) => {
1289
+ try {
1290
+ const result = await makeCkanRequest(
1291
+ params.server_url,
1292
+ "datastore_search_sql",
1293
+ { sql: params.sql }
1294
+ );
1295
+ if (params.response_format === "json" /* JSON */) {
1296
+ return {
1297
+ content: [{ type: "text", text: truncateText(JSON.stringify(result, null, 2)) }],
1298
+ structuredContent: result
1299
+ };
1300
+ }
1301
+ const records = result.records || [];
1302
+ const fieldIds = result.fields?.map((field) => field.id) || Object.keys(records[0] || {});
1303
+ let markdown = `# DataStore SQL Results
1304
+
1305
+ `;
1306
+ markdown += `**Server**: ${params.server_url}
1307
+ `;
1308
+ markdown += `**SQL**: \`${params.sql}\`
1309
+ `;
1310
+ markdown += `**Returned**: ${records.length} records
1311
+
1312
+ `;
1313
+ if (result.fields && result.fields.length > 0) {
1314
+ markdown += `## Fields
1315
+
1316
+ `;
1317
+ markdown += result.fields.map((field) => `- **${field.id}** (${field.type})`).join("\n") + "\n\n";
1318
+ }
1319
+ if (records.length > 0 && fieldIds.length > 0) {
1320
+ markdown += `## Records
1321
+
1322
+ `;
1323
+ const displayFields = fieldIds.slice(0, 8);
1324
+ markdown += `| ${displayFields.join(" | ")} |
1325
+ `;
1326
+ markdown += `| ${displayFields.map(() => "---").join(" | ")} |
1327
+ `;
1328
+ for (const record of records.slice(0, 50)) {
1329
+ const values = displayFields.map((field) => {
1330
+ const value = record[field];
1331
+ if (value === null || value === void 0) return "-";
1332
+ const text = String(value);
1333
+ return text.length > 50 ? text.substring(0, 47) + "..." : text;
1334
+ });
1335
+ markdown += `| ${values.join(" | ")} |
1336
+ `;
1337
+ }
1338
+ if (records.length > 50) {
1339
+ markdown += `
1340
+ ... and ${records.length - 50} more records
1341
+ `;
1342
+ }
1343
+ markdown += "\n";
1344
+ } else {
1345
+ markdown += "No records returned by the SQL query.\n";
1346
+ }
1347
+ return {
1348
+ content: [{ type: "text", text: truncateText(markdown) }]
1349
+ };
1350
+ } catch (error) {
1351
+ return {
1352
+ content: [{
1353
+ type: "text",
1354
+ text: `Error querying DataStore SQL: ${error instanceof Error ? error.message : String(error)}`
1355
+ }],
1356
+ isError: true
1357
+ };
1358
+ }
1359
+ }
1360
+ );
1001
1361
  }
1002
1362
 
1003
1363
  // src/tools/status.ts
@@ -1727,7 +2087,7 @@ function registerAllResources(server2) {
1727
2087
  function createServer() {
1728
2088
  return new McpServer({
1729
2089
  name: "ckan-mcp-server",
1730
- version: "0.4.3"
2090
+ version: "0.4.5"
1731
2091
  });
1732
2092
  }
1733
2093
  function registerAll(server2) {
package/dist/worker.js CHANGED
@@ -518,7 +518,7 @@ Returns:
518
518
  `,i+=`| Group | Datasets |
519
519
  `,i+=`|-------|----------|
520
520
  `;for(let a of o)i+=`| ${a.display_name||a.name} | ${a.count} |
521
- `}return{content:[{type:"text",text:re(i)}]}}catch(r){return{content:[{type:"text",text:`Error searching groups: ${r instanceof Error?r.message:String(r)}`}],isError:!0}}})}function Jn(t){let e=t.hostname;if(!e)throw new Error("Invalid ckan:// URI: missing server hostname");let r=t.pathname.split("/").filter(a=>a.length>0);if(r.length<2)throw new Error(`Invalid ckan:// URI: expected /{type}/{id}, got ${t.pathname}`);let[n,...o]=r,s=o.join("/");if(!n||!s)throw new Error("Invalid ckan:// URI: missing type or id");return{server:`https://${e}`,type:n,id:s}}function lv(t){t.registerResource("ckan-dataset",new vr("ckan://{server}/dataset/{id}",{list:void 0}),{title:"CKAN Dataset",description:"Access dataset metadata from any CKAN server. URI format: ckan://{server}/dataset/{id}",mimeType:"application/json"},async(e,r)=>{try{let{server:n}=Jn(e),o=r.id,s=await le(n,"package_show",{id:o}),i=re(JSON.stringify(s,null,2));return{contents:[{uri:e.href,mimeType:"application/json",text:i}]}}catch(n){let o=n instanceof Error?n.message:String(n);return{contents:[{uri:e.href,mimeType:"text/plain",text:`Error fetching dataset: ${o}`}]}}})}function dv(t){t.registerResource("ckan-resource",new vr("ckan://{server}/resource/{id}",{list:void 0}),{title:"CKAN Resource",description:"Access resource metadata and download URL from any CKAN server. URI format: ckan://{server}/resource/{id}",mimeType:"application/json"},async(e,r)=>{try{let{server:n}=Jn(e),o=r.id,s=await le(n,"resource_show",{id:o}),i=re(JSON.stringify(s,null,2));return{contents:[{uri:e.href,mimeType:"application/json",text:i}]}}catch(n){let o=n instanceof Error?n.message:String(n);return{contents:[{uri:e.href,mimeType:"text/plain",text:`Error fetching resource: ${o}`}]}}})}function pv(t){t.registerResource("ckan-organization",new vr("ckan://{server}/organization/{name}",{list:void 0}),{title:"CKAN Organization",description:"Access organization metadata from any CKAN server. URI format: ckan://{server}/organization/{name}",mimeType:"application/json"},async(e,r)=>{try{let{server:n}=Jn(e),o=r.name,s=await le(n,"organization_show",{id:o,include_datasets:!1}),i=re(JSON.stringify(s,null,2));return{contents:[{uri:e.href,mimeType:"application/json",text:i}]}}catch(n){let o=n instanceof Error?n.message:String(n);return{contents:[{uri:e.href,mimeType:"text/plain",text:`Error fetching organization: ${o}`}]}}})}function fv(t){lv(t),dv(t),pv(t)}function mv(){return new na({name:"ckan-mcp-server",version:"0.4.3"})}function hv(t){nv(t),ov(t),sv(t),iv(t),av(t),uv(t),fv(t)}var va=class{constructor(e={}){this._started=!1,this._streamMapping=new Map,this._requestToStreamMapping=new Map,this._requestResponseMap=new Map,this._initialized=!1,this._enableJsonResponse=!1,this._standaloneSseStreamId="_GET_stream",this.sessionIdGenerator=e.sessionIdGenerator,this._enableJsonResponse=e.enableJsonResponse??!1,this._eventStore=e.eventStore,this._onsessioninitialized=e.onsessioninitialized,this._onsessionclosed=e.onsessionclosed,this._allowedHosts=e.allowedHosts,this._allowedOrigins=e.allowedOrigins,this._enableDnsRebindingProtection=e.enableDnsRebindingProtection??!1,this._retryInterval=e.retryInterval}async start(){if(this._started)throw new Error("Transport already started");this._started=!0}createJsonErrorResponse(e,r,n,o){let s={code:r,message:n};return o?.data!==void 0&&(s.data=o.data),new Response(JSON.stringify({jsonrpc:"2.0",error:s,id:null}),{status:e,headers:{"Content-Type":"application/json",...o?.headers}})}validateRequestHeaders(e){if(this._enableDnsRebindingProtection){if(this._allowedHosts&&this._allowedHosts.length>0){let r=e.headers.get("host");if(!r||!this._allowedHosts.includes(r)){let n=`Invalid Host header: ${r}`;return this.onerror?.(new Error(n)),this.createJsonErrorResponse(403,-32e3,n)}}if(this._allowedOrigins&&this._allowedOrigins.length>0){let r=e.headers.get("origin");if(r&&!this._allowedOrigins.includes(r)){let n=`Invalid Origin header: ${r}`;return this.onerror?.(new Error(n)),this.createJsonErrorResponse(403,-32e3,n)}}}}async handleRequest(e,r){let n=this.validateRequestHeaders(e);if(n)return n;switch(e.method){case"POST":return this.handlePostRequest(e,r);case"GET":return this.handleGetRequest(e);case"DELETE":return this.handleDeleteRequest(e);default:return this.handleUnsupportedRequest()}}async writePrimingEvent(e,r,n,o){if(!this._eventStore||o<"2025-11-25")return;let s=await this._eventStore.storeEvent(n,{}),i=`id: ${s}
521
+ `}return{content:[{type:"text",text:re(i)}]}}catch(r){return{content:[{type:"text",text:`Error searching groups: ${r instanceof Error?r.message:String(r)}`}],isError:!0}}})}function Jn(t){let e=t.hostname;if(!e)throw new Error("Invalid ckan:// URI: missing server hostname");let r=t.pathname.split("/").filter(a=>a.length>0);if(r.length<2)throw new Error(`Invalid ckan:// URI: expected /{type}/{id}, got ${t.pathname}`);let[n,...o]=r,s=o.join("/");if(!n||!s)throw new Error("Invalid ckan:// URI: missing type or id");return{server:`https://${e}`,type:n,id:s}}function lv(t){t.registerResource("ckan-dataset",new vr("ckan://{server}/dataset/{id}",{list:void 0}),{title:"CKAN Dataset",description:"Access dataset metadata from any CKAN server. URI format: ckan://{server}/dataset/{id}",mimeType:"application/json"},async(e,r)=>{try{let{server:n}=Jn(e),o=r.id,s=await le(n,"package_show",{id:o}),i=re(JSON.stringify(s,null,2));return{contents:[{uri:e.href,mimeType:"application/json",text:i}]}}catch(n){let o=n instanceof Error?n.message:String(n);return{contents:[{uri:e.href,mimeType:"text/plain",text:`Error fetching dataset: ${o}`}]}}})}function dv(t){t.registerResource("ckan-resource",new vr("ckan://{server}/resource/{id}",{list:void 0}),{title:"CKAN Resource",description:"Access resource metadata and download URL from any CKAN server. URI format: ckan://{server}/resource/{id}",mimeType:"application/json"},async(e,r)=>{try{let{server:n}=Jn(e),o=r.id,s=await le(n,"resource_show",{id:o}),i=re(JSON.stringify(s,null,2));return{contents:[{uri:e.href,mimeType:"application/json",text:i}]}}catch(n){let o=n instanceof Error?n.message:String(n);return{contents:[{uri:e.href,mimeType:"text/plain",text:`Error fetching resource: ${o}`}]}}})}function pv(t){t.registerResource("ckan-organization",new vr("ckan://{server}/organization/{name}",{list:void 0}),{title:"CKAN Organization",description:"Access organization metadata from any CKAN server. URI format: ckan://{server}/organization/{name}",mimeType:"application/json"},async(e,r)=>{try{let{server:n}=Jn(e),o=r.name,s=await le(n,"organization_show",{id:o,include_datasets:!1}),i=re(JSON.stringify(s,null,2));return{contents:[{uri:e.href,mimeType:"application/json",text:i}]}}catch(n){let o=n instanceof Error?n.message:String(n);return{contents:[{uri:e.href,mimeType:"text/plain",text:`Error fetching organization: ${o}`}]}}})}function fv(t){lv(t),dv(t),pv(t)}function mv(){return new na({name:"ckan-mcp-server",version:"0.4.5"})}function hv(t){nv(t),ov(t),sv(t),iv(t),av(t),uv(t),fv(t)}var va=class{constructor(e={}){this._started=!1,this._streamMapping=new Map,this._requestToStreamMapping=new Map,this._requestResponseMap=new Map,this._initialized=!1,this._enableJsonResponse=!1,this._standaloneSseStreamId="_GET_stream",this.sessionIdGenerator=e.sessionIdGenerator,this._enableJsonResponse=e.enableJsonResponse??!1,this._eventStore=e.eventStore,this._onsessioninitialized=e.onsessioninitialized,this._onsessionclosed=e.onsessionclosed,this._allowedHosts=e.allowedHosts,this._allowedOrigins=e.allowedOrigins,this._enableDnsRebindingProtection=e.enableDnsRebindingProtection??!1,this._retryInterval=e.retryInterval}async start(){if(this._started)throw new Error("Transport already started");this._started=!0}createJsonErrorResponse(e,r,n,o){let s={code:r,message:n};return o?.data!==void 0&&(s.data=o.data),new Response(JSON.stringify({jsonrpc:"2.0",error:s,id:null}),{status:e,headers:{"Content-Type":"application/json",...o?.headers}})}validateRequestHeaders(e){if(this._enableDnsRebindingProtection){if(this._allowedHosts&&this._allowedHosts.length>0){let r=e.headers.get("host");if(!r||!this._allowedHosts.includes(r)){let n=`Invalid Host header: ${r}`;return this.onerror?.(new Error(n)),this.createJsonErrorResponse(403,-32e3,n)}}if(this._allowedOrigins&&this._allowedOrigins.length>0){let r=e.headers.get("origin");if(r&&!this._allowedOrigins.includes(r)){let n=`Invalid Origin header: ${r}`;return this.onerror?.(new Error(n)),this.createJsonErrorResponse(403,-32e3,n)}}}}async handleRequest(e,r){let n=this.validateRequestHeaders(e);if(n)return n;switch(e.method){case"POST":return this.handlePostRequest(e,r);case"GET":return this.handleGetRequest(e);case"DELETE":return this.handleDeleteRequest(e);default:return this.handleUnsupportedRequest()}}async writePrimingEvent(e,r,n,o){if(!this._eventStore||o<"2025-11-25")return;let s=await this._eventStore.storeEvent(n,{}),i=`id: ${s}
522
522
  data:
523
523
 
524
524
  `;this._retryInterval!==void 0&&(i=`id: ${s}
@@ -529,4 +529,4 @@ data:
529
529
  `;return o&&(s+=`id: ${o}
530
530
  `),s+=`data: ${JSON.stringify(n)}
531
531
 
532
- `,e.enqueue(r.encode(s)),!0}catch{return!1}}handleUnsupportedRequest(){return new Response(JSON.stringify({jsonrpc:"2.0",error:{code:-32e3,message:"Method not allowed."},id:null}),{status:405,headers:{Allow:"GET, POST, DELETE","Content-Type":"application/json"}})}async handlePostRequest(e,r){try{let n=e.headers.get("accept");if(!n?.includes("application/json")||!n.includes("text/event-stream"))return this.createJsonErrorResponse(406,-32e3,"Not Acceptable: Client must accept both application/json and text/event-stream");let o=e.headers.get("content-type");if(!o||!o.includes("application/json"))return this.createJsonErrorResponse(415,-32e3,"Unsupported Media Type: Content-Type must be application/json");let s={headers:Object.fromEntries(e.headers.entries())},i;if(r?.parsedBody!==void 0)i=r.parsedBody;else try{i=await e.json()}catch{return this.createJsonErrorResponse(400,-32700,"Parse error: Invalid JSON")}let a;try{Array.isArray(i)?a=i.map(_=>Zu.parse(_)):a=[Zu.parse(i)]}catch{return this.createJsonErrorResponse(400,-32700,"Parse error: Invalid JSON-RPC message")}let c=a.some(Mu);if(c){if(this._initialized&&this.sessionId!==void 0)return this.createJsonErrorResponse(400,-32600,"Invalid Request: Server already initialized");if(a.length>1)return this.createJsonErrorResponse(400,-32600,"Invalid Request: Only one initialization request is allowed");this.sessionId=this.sessionIdGenerator?.(),this._initialized=!0,this.sessionId&&this._onsessioninitialized&&await Promise.resolve(this._onsessioninitialized(this.sessionId))}if(!c){let _=this.validateSession(e);if(_)return _;let x=this.validateProtocolVersion(e);if(x)return x}if(!a.some(cr)){for(let _ of a)this.onmessage?.(_,{authInfo:r?.authInfo,requestInfo:s});return new Response(null,{status:202})}let l=crypto.randomUUID(),d=a.find(_=>Mu(_)),g=d?d.params.protocolVersion:e.headers.get("mcp-protocol-version")??Tm;if(this._enableJsonResponse)return new Promise(_=>{this._streamMapping.set(l,{resolveJson:_,cleanup:()=>{this._streamMapping.delete(l)}});for(let x of a)cr(x)&&this._requestToStreamMapping.set(x.id,l);for(let x of a)this.onmessage?.(x,{authInfo:r?.authInfo,requestInfo:s})});let h=new TextEncoder,p,f=new ReadableStream({start:_=>{p=_},cancel:()=>{this._streamMapping.delete(l)}}),m={"Content-Type":"text/event-stream","Cache-Control":"no-cache",Connection:"keep-alive"};this.sessionId!==void 0&&(m["mcp-session-id"]=this.sessionId);for(let _ of a)cr(_)&&(this._streamMapping.set(l,{controller:p,encoder:h,cleanup:()=>{this._streamMapping.delete(l);try{p.close()}catch{}}}),this._requestToStreamMapping.set(_.id,l));await this.writePrimingEvent(p,h,l,g);for(let _ of a){let x,w;cr(_)&&this._eventStore&&g>="2025-11-25"&&(x=()=>{this.closeSSEStream(_.id)},w=()=>{this.closeStandaloneSSEStream()}),this.onmessage?.(_,{authInfo:r?.authInfo,requestInfo:s,closeSSEStream:x,closeStandaloneSSEStream:w})}return new Response(f,{status:200,headers:m})}catch(n){return this.onerror?.(n),this.createJsonErrorResponse(400,-32700,"Parse error",{data:String(n)})}}async handleDeleteRequest(e){let r=this.validateSession(e);if(r)return r;let n=this.validateProtocolVersion(e);return n||(await Promise.resolve(this._onsessionclosed?.(this.sessionId)),await this.close(),new Response(null,{status:200}))}validateSession(e){if(this.sessionIdGenerator===void 0)return;if(!this._initialized)return this.createJsonErrorResponse(400,-32e3,"Bad Request: Server not initialized");let r=e.headers.get("mcp-session-id");if(!r)return this.createJsonErrorResponse(400,-32e3,"Bad Request: Mcp-Session-Id header is required");if(r!==this.sessionId)return this.createJsonErrorResponse(404,-32001,"Session not found")}validateProtocolVersion(e){let r=e.headers.get("mcp-protocol-version");if(r!==null&&!po.includes(r))return this.createJsonErrorResponse(400,-32e3,`Bad Request: Unsupported protocol version: ${r} (supported versions: ${po.join(", ")})`)}async close(){this._streamMapping.forEach(({cleanup:e})=>{e()}),this._streamMapping.clear(),this._requestResponseMap.clear(),this.onclose?.()}closeSSEStream(e){let r=this._requestToStreamMapping.get(e);if(!r)return;let n=this._streamMapping.get(r);n&&n.cleanup()}closeStandaloneSSEStream(){let e=this._streamMapping.get(this._standaloneSseStreamId);e&&e.cleanup()}async send(e,r){let n=r?.relatedRequestId;if((Ut(e)||vn(e))&&(n=e.id),n===void 0){if(Ut(e)||vn(e))throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request");let i;this._eventStore&&(i=await this._eventStore.storeEvent(this._standaloneSseStreamId,e));let a=this._streamMapping.get(this._standaloneSseStreamId);if(a===void 0)return;a.controller&&a.encoder&&this.writeSSEEvent(a.controller,a.encoder,e,i);return}let o=this._requestToStreamMapping.get(n);if(!o)throw new Error(`No connection established for request ID: ${String(n)}`);let s=this._streamMapping.get(o);if(!this._enableJsonResponse&&s?.controller&&s?.encoder){let i;this._eventStore&&(i=await this._eventStore.storeEvent(o,e)),this.writeSSEEvent(s.controller,s.encoder,e,i)}if(Ut(e)||vn(e)){this._requestResponseMap.set(n,e);let i=Array.from(this._requestToStreamMapping.entries()).filter(([c,u])=>u===o).map(([c])=>c);if(i.every(c=>this._requestResponseMap.has(c))){if(!s)throw new Error(`No connection established for request ID: ${String(n)}`);if(this._enableJsonResponse&&s.resolveJson){let c={"Content-Type":"application/json"};this.sessionId!==void 0&&(c["mcp-session-id"]=this.sessionId);let u=i.map(l=>this._requestResponseMap.get(l));u.length===1?s.resolveJson(new Response(JSON.stringify(u[0]),{status:200,headers:c})):s.resolveJson(new Response(JSON.stringify(u),{status:200,headers:c}))}else s.cleanup();for(let c of i)this._requestResponseMap.delete(c),this._requestToStreamMapping.delete(c)}}}};var gv=mv();hv(gv);var yv=new va({sessionIdGenerator:void 0,enableJsonResponse:!0});await gv.connect(yv);var c4={async fetch(t){let e=new URL(t.url);if(t.method==="GET"&&e.pathname==="/health")return new Response(JSON.stringify({status:"ok",version:"0.4.3",tools:11,resources:3,runtime:"cloudflare-workers"}),{headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}});if(e.pathname==="/mcp")try{let r=await yv.handleRequest(t),n=new Headers(r.headers);return n.set("Access-Control-Allow-Origin","*"),new Response(r.body,{status:r.status,statusText:r.statusText,headers:n})}catch(r){return console.error("Worker error:",r),new Response(JSON.stringify({jsonrpc:"2.0",error:{code:-32603,message:"Internal error",data:r instanceof Error?r.message:String(r)},id:null}),{status:500,headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}})}return new Response("Not Found",{status:404,headers:{"Access-Control-Allow-Origin":"*"}})}};export{c4 as default};
532
+ `,e.enqueue(r.encode(s)),!0}catch{return!1}}handleUnsupportedRequest(){return new Response(JSON.stringify({jsonrpc:"2.0",error:{code:-32e3,message:"Method not allowed."},id:null}),{status:405,headers:{Allow:"GET, POST, DELETE","Content-Type":"application/json"}})}async handlePostRequest(e,r){try{let n=e.headers.get("accept");if(!n?.includes("application/json")||!n.includes("text/event-stream"))return this.createJsonErrorResponse(406,-32e3,"Not Acceptable: Client must accept both application/json and text/event-stream");let o=e.headers.get("content-type");if(!o||!o.includes("application/json"))return this.createJsonErrorResponse(415,-32e3,"Unsupported Media Type: Content-Type must be application/json");let s={headers:Object.fromEntries(e.headers.entries())},i;if(r?.parsedBody!==void 0)i=r.parsedBody;else try{i=await e.json()}catch{return this.createJsonErrorResponse(400,-32700,"Parse error: Invalid JSON")}let a;try{Array.isArray(i)?a=i.map(_=>Zu.parse(_)):a=[Zu.parse(i)]}catch{return this.createJsonErrorResponse(400,-32700,"Parse error: Invalid JSON-RPC message")}let c=a.some(Mu);if(c){if(this._initialized&&this.sessionId!==void 0)return this.createJsonErrorResponse(400,-32600,"Invalid Request: Server already initialized");if(a.length>1)return this.createJsonErrorResponse(400,-32600,"Invalid Request: Only one initialization request is allowed");this.sessionId=this.sessionIdGenerator?.(),this._initialized=!0,this.sessionId&&this._onsessioninitialized&&await Promise.resolve(this._onsessioninitialized(this.sessionId))}if(!c){let _=this.validateSession(e);if(_)return _;let x=this.validateProtocolVersion(e);if(x)return x}if(!a.some(cr)){for(let _ of a)this.onmessage?.(_,{authInfo:r?.authInfo,requestInfo:s});return new Response(null,{status:202})}let l=crypto.randomUUID(),d=a.find(_=>Mu(_)),g=d?d.params.protocolVersion:e.headers.get("mcp-protocol-version")??Tm;if(this._enableJsonResponse)return new Promise(_=>{this._streamMapping.set(l,{resolveJson:_,cleanup:()=>{this._streamMapping.delete(l)}});for(let x of a)cr(x)&&this._requestToStreamMapping.set(x.id,l);for(let x of a)this.onmessage?.(x,{authInfo:r?.authInfo,requestInfo:s})});let h=new TextEncoder,p,f=new ReadableStream({start:_=>{p=_},cancel:()=>{this._streamMapping.delete(l)}}),m={"Content-Type":"text/event-stream","Cache-Control":"no-cache",Connection:"keep-alive"};this.sessionId!==void 0&&(m["mcp-session-id"]=this.sessionId);for(let _ of a)cr(_)&&(this._streamMapping.set(l,{controller:p,encoder:h,cleanup:()=>{this._streamMapping.delete(l);try{p.close()}catch{}}}),this._requestToStreamMapping.set(_.id,l));await this.writePrimingEvent(p,h,l,g);for(let _ of a){let x,w;cr(_)&&this._eventStore&&g>="2025-11-25"&&(x=()=>{this.closeSSEStream(_.id)},w=()=>{this.closeStandaloneSSEStream()}),this.onmessage?.(_,{authInfo:r?.authInfo,requestInfo:s,closeSSEStream:x,closeStandaloneSSEStream:w})}return new Response(f,{status:200,headers:m})}catch(n){return this.onerror?.(n),this.createJsonErrorResponse(400,-32700,"Parse error",{data:String(n)})}}async handleDeleteRequest(e){let r=this.validateSession(e);if(r)return r;let n=this.validateProtocolVersion(e);return n||(await Promise.resolve(this._onsessionclosed?.(this.sessionId)),await this.close(),new Response(null,{status:200}))}validateSession(e){if(this.sessionIdGenerator===void 0)return;if(!this._initialized)return this.createJsonErrorResponse(400,-32e3,"Bad Request: Server not initialized");let r=e.headers.get("mcp-session-id");if(!r)return this.createJsonErrorResponse(400,-32e3,"Bad Request: Mcp-Session-Id header is required");if(r!==this.sessionId)return this.createJsonErrorResponse(404,-32001,"Session not found")}validateProtocolVersion(e){let r=e.headers.get("mcp-protocol-version");if(r!==null&&!po.includes(r))return this.createJsonErrorResponse(400,-32e3,`Bad Request: Unsupported protocol version: ${r} (supported versions: ${po.join(", ")})`)}async close(){this._streamMapping.forEach(({cleanup:e})=>{e()}),this._streamMapping.clear(),this._requestResponseMap.clear(),this.onclose?.()}closeSSEStream(e){let r=this._requestToStreamMapping.get(e);if(!r)return;let n=this._streamMapping.get(r);n&&n.cleanup()}closeStandaloneSSEStream(){let e=this._streamMapping.get(this._standaloneSseStreamId);e&&e.cleanup()}async send(e,r){let n=r?.relatedRequestId;if((Ut(e)||vn(e))&&(n=e.id),n===void 0){if(Ut(e)||vn(e))throw new Error("Cannot send a response on a standalone SSE stream unless resuming a previous client request");let i;this._eventStore&&(i=await this._eventStore.storeEvent(this._standaloneSseStreamId,e));let a=this._streamMapping.get(this._standaloneSseStreamId);if(a===void 0)return;a.controller&&a.encoder&&this.writeSSEEvent(a.controller,a.encoder,e,i);return}let o=this._requestToStreamMapping.get(n);if(!o)throw new Error(`No connection established for request ID: ${String(n)}`);let s=this._streamMapping.get(o);if(!this._enableJsonResponse&&s?.controller&&s?.encoder){let i;this._eventStore&&(i=await this._eventStore.storeEvent(o,e)),this.writeSSEEvent(s.controller,s.encoder,e,i)}if(Ut(e)||vn(e)){this._requestResponseMap.set(n,e);let i=Array.from(this._requestToStreamMapping.entries()).filter(([c,u])=>u===o).map(([c])=>c);if(i.every(c=>this._requestResponseMap.has(c))){if(!s)throw new Error(`No connection established for request ID: ${String(n)}`);if(this._enableJsonResponse&&s.resolveJson){let c={"Content-Type":"application/json"};this.sessionId!==void 0&&(c["mcp-session-id"]=this.sessionId);let u=i.map(l=>this._requestResponseMap.get(l));u.length===1?s.resolveJson(new Response(JSON.stringify(u[0]),{status:200,headers:c})):s.resolveJson(new Response(JSON.stringify(u),{status:200,headers:c}))}else s.cleanup();for(let c of i)this._requestResponseMap.delete(c),this._requestToStreamMapping.delete(c)}}}};var gv=mv();hv(gv);var yv=new va({sessionIdGenerator:void 0,enableJsonResponse:!0});await gv.connect(yv);var c2={async fetch(t){let e=new URL(t.url);if(t.method==="GET"&&e.pathname==="/health")return new Response(JSON.stringify({status:"ok",version:"0.4.5",tools:12,resources:3,runtime:"cloudflare-workers"}),{headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}});if(e.pathname==="/mcp")try{let r=await yv.handleRequest(t),n=new Headers(r.headers);return n.set("Access-Control-Allow-Origin","*"),new Response(r.body,{status:r.status,statusText:r.statusText,headers:n})}catch(r){return console.error("Worker error:",r),new Response(JSON.stringify({jsonrpc:"2.0",error:{code:-32603,message:"Internal error",data:r instanceof Error?r.message:String(r)},id:null}),{status:500,headers:{"Content-Type":"application/json","Access-Control-Allow-Origin":"*"}})}return new Response("Not Found",{status:404,headers:{"Access-Control-Allow-Origin":"*"}})}};export{c2 as default};
@@ -0,0 +1,17 @@
1
+ # Change: Add ckan_analyze_dataset_structure tool
2
+
3
+ ## Why
4
+ Users need quick schema and quality signals without manual inspection.
5
+
6
+ ## What Changes
7
+ - Add MCP tool `ckan_analyze_dataset_structure` for schema summaries.
8
+ - Use resource schema or DataStore fields for columns and types.
9
+ - Compute null-rate stats when DataStore is available.
10
+
11
+ ## Impact
12
+ - Affected specs: `ckan-insights`
13
+ - Affected code: `src/tools/datastore.ts` and resource utilities
14
+
15
+ ## Open Questions
16
+ - Default `sample_size` for null-rate analysis?
17
+ - How to select resource when only dataset id is provided?
@@ -0,0 +1,7 @@
1
+ ## ADDED Requirements
2
+ ### Requirement: Analyze dataset structure
3
+ The system SHALL provide a `ckan_analyze_dataset_structure` tool that accepts `server_url`, a `resource_id` or `dataset_id`, and optional `sample_size`, returning column names and types; when a DataStore is available it MUST compute per-column null rates from a sampled query.
4
+
5
+ #### Scenario: Schema summary with null rates
6
+ - **WHEN** the tool is invoked for a DataStore-enabled resource
7
+ - **THEN** the system returns columns, types, and null-rate statistics
@@ -0,0 +1,6 @@
1
+ ## 1. Implementation
2
+ - [ ] 1.1 Add tool schema and registration
3
+ - [ ] 1.2 Resolve resource selection (resource_id or dataset)
4
+ - [ ] 1.3 Compute schema summary + null rates
5
+ - [ ] 1.4 Add fixtures + tests
6
+ - [ ] 1.5 Update README/EXAMPLES
@@ -0,0 +1,17 @@
1
+ # Change: Add ckan_analyze_dataset_updates tool
2
+
3
+ ## Why
4
+ Users need quick insight on dataset freshness and update cadence.
5
+
6
+ ## What Changes
7
+ - Add MCP tool `ckan_analyze_dataset_updates` for dataset freshness analysis.
8
+ - Combine `metadata_modified` and resource `last_modified` to estimate cadence.
9
+ - Flag stale datasets based on a configurable threshold.
10
+
11
+ ## Impact
12
+ - Affected specs: `ckan-insights`
13
+ - Affected code: `src/tools/package.ts` and `src/tools/datastore.ts`
14
+
15
+ ## Open Questions
16
+ - Default `stale_days` threshold?
17
+ - How to handle resources without `last_modified`?
@@ -0,0 +1,7 @@
1
+ ## ADDED Requirements
2
+ ### Requirement: Analyze dataset updates
3
+ The system SHALL provide a `ckan_analyze_dataset_updates` tool that accepts `server_url`, `dataset_id`, and optional `stale_days`, and returns update cadence based on `metadata_modified` and resource `last_modified`, including a `stale` flag when the most recent update exceeds the threshold.
4
+
5
+ #### Scenario: Update cadence and stale flag
6
+ - **WHEN** the tool is invoked with a dataset id and stale threshold
7
+ - **THEN** the system returns last update timestamps, cadence summary, and a stale flag
@@ -0,0 +1,6 @@
1
+ ## 1. Implementation
2
+ - [ ] 1.1 Add tool schema and registration
3
+ - [ ] 1.2 Compute update cadence from metadata/resource dates
4
+ - [ ] 1.3 Add stale detection output
5
+ - [ ] 1.4 Add fixtures + tests
6
+ - [ ] 1.5 Update README/EXAMPLES
@@ -0,0 +1,17 @@
1
+ # Change: Add ckan_audit tool
2
+
3
+ ## Why
4
+ Operators need an automated probe to detect datastore/SQL/alias support and capture portal quirks.
5
+
6
+ ## What Changes
7
+ - Add MCP tool `ckan_audit` to probe CKAN API capabilities.
8
+ - Detect DataStore availability, SQL endpoint, and datastore id alias support.
9
+ - Return suggested overrides for portal configuration.
10
+
11
+ ## Impact
12
+ - Affected specs: `ckan-insights`
13
+ - Affected code: `src/tools/status.ts` or new insights module
14
+
15
+ ## Open Questions
16
+ - Should audit run only read-only GET probes?
17
+ - Which overrides format to return (JSON block vs markdown list)?
@@ -0,0 +1,7 @@
1
+ ## ADDED Requirements
2
+ ### Requirement: Audit CKAN portal capabilities
3
+ The system SHALL provide a `ckan_audit` tool that accepts `server_url` and returns detected capabilities for DataStore, SQL endpoint availability, and datastore id alias support, plus suggested portal override values.
4
+
5
+ #### Scenario: Capability probe
6
+ - **WHEN** the tool probes a CKAN portal
7
+ - **THEN** the response includes datastore availability, SQL support, alias support, and recommended overrides
@@ -0,0 +1,6 @@
1
+ ## 1. Implementation
2
+ - [ ] 1.1 Add tool schema and registration
3
+ - [ ] 1.2 Probe datastore_search and datastore_search_sql
4
+ - [ ] 1.3 Detect datastore id alias support
5
+ - [ ] 1.4 Return override suggestions
6
+ - [ ] 1.5 Add fixtures + tests
@@ -0,0 +1,17 @@
1
+ # Change: Add ckan_dataset_insights tool
2
+
3
+ ## Why
4
+ Users want a single call that returns ranked datasets plus freshness and structure insights.
5
+
6
+ ## What Changes
7
+ - Add MCP tool `ckan_dataset_insights` as a wrapper.
8
+ - Combine outputs from `ckan_find_relevant_datasets`, `ckan_analyze_dataset_updates`, and `ckan_analyze_dataset_structure`.
9
+ - Provide compact summary per dataset.
10
+
11
+ ## Impact
12
+ - Affected specs: `ckan-insights`
13
+ - Affected code: new insights module orchestrating existing tools
14
+
15
+ ## Open Questions
16
+ - Default number of datasets to enrich?
17
+ - Should structure analysis be optional for non-DataStore resources?
@@ -0,0 +1,7 @@
1
+ ## ADDED Requirements
2
+ ### Requirement: Dataset insights wrapper
3
+ The system SHALL provide a `ckan_dataset_insights` tool that accepts `server_url`, `query`, `limit`, and optional analysis parameters, and returns per-dataset summaries combining relevance scores, update cadence, and structure metrics.
4
+
5
+ #### Scenario: Combined insights output
6
+ - **WHEN** the tool is invoked with a query and limit
7
+ - **THEN** the system returns the top N datasets with combined relevance, update, and structure insights
@@ -0,0 +1,6 @@
1
+ ## 1. Implementation
2
+ - [ ] 1.1 Add tool schema and registration
3
+ - [ ] 1.2 Orchestrate find/update/structure analyses
4
+ - [ ] 1.3 Define merged output format
5
+ - [ ] 1.4 Add fixtures + tests
6
+ - [ ] 1.5 Update README/EXAMPLES
@@ -0,0 +1,17 @@
1
+ # Change: Add ckan_find_relevant_datasets tool
2
+
3
+ ## Why
4
+ Ranked discovery helps surface the best datasets for a query without manual sorting.
5
+
6
+ ## What Changes
7
+ - Add MCP tool `ckan_find_relevant_datasets` wrapping `package_search`.
8
+ - Score results using weighted fields (title, notes, tags, organization).
9
+ - Return top N datasets with score breakdown.
10
+
11
+ ## Impact
12
+ - Affected specs: `ckan-insights`
13
+ - Affected code: `src/tools/package.ts` (or new insights module)
14
+
15
+ ## Open Questions
16
+ - Default weights for title/notes/tags/organization?
17
+ - Default `limit` when omitted?
@@ -0,0 +1,7 @@
1
+ ## ADDED Requirements
2
+ ### Requirement: Find relevant datasets
3
+ The system SHALL provide a `ckan_find_relevant_datasets` tool that accepts `server_url`, `query`, `limit`, and optional weights for title, notes, tags, and organization, and returns the top N datasets ranked by score with a per-field score breakdown.
4
+
5
+ #### Scenario: Ranked results returned
6
+ - **WHEN** the tool is invoked with a query and limit
7
+ - **THEN** the system uses `package_search` results to return the top N datasets with numeric scores and field contributions
@@ -0,0 +1,6 @@
1
+ ## 1. Implementation
2
+ - [ ] 1.1 Add tool schema and registration
3
+ - [ ] 1.2 Implement weighted scoring + ranking
4
+ - [ ] 1.3 Define markdown/json output shape
5
+ - [ ] 1.4 Add fixtures + tests
6
+ - [ ] 1.5 Update README/EXAMPLES
@@ -0,0 +1,12 @@
1
+ # ckan-insights Specification
2
+
3
+ ## Purpose
4
+ Describe dataset insight tools (relevance, freshness, structure) once implemented.
5
+
6
+ ## Requirements
7
+ ### Requirement: Insights capability reserved
8
+ The system SHALL maintain this specification to document dataset insight tools when they are added.
9
+
10
+ #### Scenario: Insight tools documented
11
+ - **WHEN** new insight tools are implemented
12
+ - **THEN** their requirements are captured in this specification
@@ -0,0 +1,344 @@
1
+ # cloudflare-deployment Specification
2
+
3
+ ## Purpose
4
+ TBD - created by archiving change add-cloudflare-workers. Update Purpose after archive.
5
+ ## Requirements
6
+ ### Requirement: Workers Entry Point
7
+
8
+ The system SHALL provide a Cloudflare Workers-compatible entry point that handles HTTP requests using the Workers fetch API.
9
+
10
+ #### Scenario: Health check endpoint
11
+
12
+ - **WHEN** client sends `GET /health` to Workers endpoint
13
+ - **THEN** server returns JSON with status, version, tool count, and resource count
14
+
15
+ #### Scenario: MCP protocol endpoint
16
+
17
+ - **WHEN** client sends `POST /mcp` with JSON-RPC request to Workers endpoint
18
+ - **THEN** server processes MCP request and returns JSON-RPC response
19
+
20
+ #### Scenario: Invalid route
21
+
22
+ - **WHEN** client requests any path other than `/health` or `/mcp`
23
+ - **THEN** server returns 404 Not Found
24
+
25
+ #### Scenario: Invalid method on MCP endpoint
26
+
27
+ - **WHEN** client sends non-POST request to `/mcp`
28
+ - **THEN** server returns 404 Not Found
29
+
30
+ ---
31
+
32
+ ### Requirement: MCP Server Integration
33
+
34
+ The system SHALL initialize MCP server instance on each Workers invocation and register all tools and resources.
35
+
36
+ #### Scenario: Server initialization
37
+
38
+ - **WHEN** Workers receives MCP request
39
+ - **THEN** server creates MCP Server instance with name "ckan-mcp-server" and version "0.4.0"
40
+
41
+ #### Scenario: Tool registration
42
+
43
+ - **WHEN** Workers initializes MCP server
44
+ - **THEN** server registers all 7 CKAN tools (package_search, package_show, organization_list, organization_show, organization_search, datastore_search, status_show)
45
+
46
+ #### Scenario: Resource registration
47
+
48
+ - **WHEN** Workers initializes MCP server
49
+ - **THEN** server registers all 3 resource templates (dataset, resource, organization)
50
+
51
+ #### Scenario: Tools list request
52
+
53
+ - **WHEN** client calls `tools/list` method via Workers endpoint
54
+ - **THEN** response includes all 7 registered CKAN tools with descriptions
55
+
56
+ ---
57
+
58
+ ### Requirement: Web Standards HTTP Client
59
+
60
+ The system SHALL use native Web Fetch API for CKAN API requests instead of Node.js-specific libraries.
61
+
62
+ #### Scenario: CKAN API request with fetch
63
+
64
+ - **WHEN** tool calls `makeCkanRequest()` in Workers runtime
65
+ - **THEN** client uses native `fetch()` with 30-second timeout
66
+
67
+ #### Scenario: Query parameter encoding
68
+
69
+ - **WHEN** CKAN request includes parameters (e.g., `q`, `rows`, `start`)
70
+ - **THEN** client encodes parameters in URL using `URLSearchParams`
71
+
72
+ #### Scenario: Request timeout
73
+
74
+ - **WHEN** CKAN API takes longer than 30 seconds
75
+ - **THEN** client aborts request using `AbortController` and returns timeout error
76
+
77
+ #### Scenario: HTTP error handling
78
+
79
+ - **WHEN** CKAN API returns HTTP error status (4xx, 5xx)
80
+ - **THEN** client throws error with status code and message
81
+
82
+ #### Scenario: CKAN API validation
83
+
84
+ - **WHEN** CKAN API returns 200 OK but `success: false`
85
+ - **THEN** client throws error with CKAN error message
86
+
87
+ ---
88
+
89
+ ### Requirement: Workers Build Configuration
90
+
91
+ The system SHALL provide separate build configuration for Workers deployment targeting browser platform with ESM format.
92
+
93
+ #### Scenario: Workers build script
94
+
95
+ - **WHEN** user runs `npm run build:worker`
96
+ - **THEN** esbuild compiles `src/worker.ts` to `dist/worker.js` in ESM format
97
+
98
+ #### Scenario: Bundle all dependencies
99
+
100
+ - **WHEN** esbuild builds Workers bundle
101
+ - **THEN** all dependencies are bundled (no external modules)
102
+
103
+ #### Scenario: Platform targeting
104
+
105
+ - **WHEN** esbuild builds Workers bundle
106
+ - **THEN** platform is set to `browser` and target is `es2022`
107
+
108
+ #### Scenario: Output format
109
+
110
+ - **WHEN** Workers build completes
111
+ - **THEN** output is ESM format (not CommonJS)
112
+
113
+ #### Scenario: Bundle size validation
114
+
115
+ - **WHEN** Workers build completes
116
+ - **THEN** bundle size is less than 1MB (Workers script size limit)
117
+
118
+ ---
119
+
120
+ ### Requirement: Wrangler Configuration
121
+
122
+ The system SHALL provide Wrangler configuration file for Workers deployment and local development.
123
+
124
+ #### Scenario: Wrangler configuration file
125
+
126
+ - **WHEN** project contains `wrangler.toml` in root directory
127
+ - **THEN** configuration specifies name, main entry point, and compatibility date
128
+
129
+ #### Scenario: Build command
130
+
131
+ - **WHEN** `wrangler deploy` or `wrangler dev` runs
132
+ - **THEN** Wrangler executes `npm run build:worker` before deployment
133
+
134
+ #### Scenario: Local development server
135
+
136
+ - **WHEN** user runs `npm run dev:worker`
137
+ - **THEN** Wrangler starts local Workers server on http://localhost:8787
138
+
139
+ ---
140
+
141
+ ### Requirement: Workers Deployment
142
+
143
+ The system SHALL deploy to Cloudflare Workers and provide public HTTPS endpoint.
144
+
145
+ #### Scenario: Deploy to Workers
146
+
147
+ - **WHEN** user runs `npm run deploy`
148
+ - **THEN** Wrangler builds and uploads worker to Cloudflare
149
+
150
+ #### Scenario: Public endpoint
151
+
152
+ - **WHEN** deployment succeeds
153
+ - **THEN** Workers script is accessible at `https://ckan-mcp-server.<account>.workers.dev`
154
+
155
+ #### Scenario: HTTPS support
156
+
157
+ - **WHEN** client accesses Workers endpoint
158
+ - **THEN** connection uses HTTPS with valid certificate
159
+
160
+ ---
161
+
162
+ ### Requirement: Tool Functionality Preservation
163
+
164
+ The system SHALL maintain identical functionality for all CKAN tools in Workers runtime compared to Node.js runtime.
165
+
166
+ #### Scenario: Package search in Workers
167
+
168
+ - **WHEN** client calls `ckan_package_search` via Workers endpoint
169
+ - **THEN** response is identical to Node.js runtime response
170
+
171
+ #### Scenario: Datastore query in Workers
172
+
173
+ - **WHEN** client calls `ckan_datastore_search` via Workers endpoint
174
+ - **THEN** response is identical to Node.js runtime response
175
+
176
+ #### Scenario: Resource template in Workers
177
+
178
+ - **WHEN** client reads `ckan://{server}/dataset/{id}` via Workers
179
+ - **THEN** response is identical to Node.js runtime response
180
+
181
+ #### Scenario: Error handling in Workers
182
+
183
+ - **WHEN** CKAN API is unreachable in Workers runtime
184
+ - **THEN** error response matches Node.js runtime error format
185
+
186
+ ---
187
+
188
+ ### Requirement: Response Format Compatibility
189
+
190
+ The system SHALL support both markdown and JSON output formats in Workers runtime.
191
+
192
+ #### Scenario: Markdown format
193
+
194
+ - **WHEN** client requests tool with `response_format: "markdown"`
195
+ - **THEN** Workers returns formatted markdown text
196
+
197
+ #### Scenario: JSON format
198
+
199
+ - **WHEN** client requests tool with `response_format: "json"`
200
+ - **THEN** Workers returns raw JSON data
201
+
202
+ #### Scenario: Character limit
203
+
204
+ - **WHEN** response exceeds CHARACTER_LIMIT (50000 characters)
205
+ - **THEN** Workers truncates response identically to Node.js runtime
206
+
207
+ ---
208
+
209
+ ### Requirement: Error Handling
210
+
211
+ The system SHALL handle Workers-specific errors gracefully with JSON-RPC error responses.
212
+
213
+ #### Scenario: Malformed JSON-RPC request
214
+
215
+ - **WHEN** client sends invalid JSON to `/mcp` endpoint
216
+ - **THEN** Workers returns JSON-RPC error with code -32700 (Parse error)
217
+
218
+ #### Scenario: Internal worker error
219
+
220
+ - **WHEN** worker encounters unexpected exception
221
+ - **THEN** Workers returns JSON-RPC error with code -32603 (Internal error)
222
+
223
+ #### Scenario: Method not found
224
+
225
+ - **WHEN** client calls non-existent MCP method
226
+ - **THEN** Workers returns JSON-RPC error with code -32601 (Method not found)
227
+
228
+ ---
229
+
230
+ ### Requirement: CORS Support
231
+
232
+ The system SHALL include CORS headers to enable browser-based MCP clients.
233
+
234
+ #### Scenario: CORS headers on success
235
+
236
+ - **WHEN** Workers returns successful response
237
+ - **THEN** response includes `Access-Control-Allow-Origin: *` header
238
+
239
+ #### Scenario: CORS headers on error
240
+
241
+ - **WHEN** Workers returns error response
242
+ - **THEN** response includes `Access-Control-Allow-Origin: *` header
243
+
244
+ #### Scenario: Preflight request
245
+
246
+ - **WHEN** browser sends OPTIONS request for CORS preflight
247
+ - **THEN** Workers returns allowed methods and headers
248
+
249
+ ---
250
+
251
+ ### Requirement: Deployment Documentation
252
+
253
+ The system SHALL provide comprehensive documentation for deploying to Cloudflare Workers.
254
+
255
+ #### Scenario: Deployment guide
256
+
257
+ - **WHEN** contributor wants to deploy Workers instance
258
+ - **THEN** `docs/DEPLOYMENT.md` provides step-by-step instructions
259
+
260
+ #### Scenario: Prerequisites documentation
261
+
262
+ - **WHEN** contributor reads DEPLOYMENT.md
263
+ - **THEN** document lists all prerequisites (Cloudflare account, wrangler CLI)
264
+
265
+ #### Scenario: Troubleshooting guide
266
+
267
+ - **WHEN** deployment fails
268
+ - **THEN** DEPLOYMENT.md includes common errors and solutions
269
+
270
+ #### Scenario: README update
271
+
272
+ - **WHEN** user reads README.md
273
+ - **THEN** deployment options section includes Cloudflare Workers option
274
+
275
+ ---
276
+
277
+ ### Requirement: Backwards Compatibility
278
+
279
+ The system SHALL maintain all existing deployment modes (stdio, self-hosted HTTP) without breaking changes.
280
+
281
+ #### Scenario: Stdio mode unchanged
282
+
283
+ - **WHEN** user runs `npm start` after Workers implementation
284
+ - **THEN** stdio transport works identically to pre-Workers version
285
+
286
+ #### Scenario: Self-hosted HTTP mode unchanged
287
+
288
+ - **WHEN** user runs `TRANSPORT=http PORT=3000 npm start` after Workers implementation
289
+ - **THEN** HTTP server works identically to pre-Workers version
290
+
291
+ #### Scenario: Existing tests pass
292
+
293
+ - **WHEN** user runs `npm test` after Workers implementation
294
+ - **THEN** all 113 existing tests pass without modification
295
+
296
+ #### Scenario: Node.js bundle unchanged
297
+
298
+ - **WHEN** user runs `npm run build` after Workers implementation
299
+ - **THEN** Node.js bundle (`dist/index.js`) is unchanged
300
+
301
+ ---
302
+
303
+ ### Requirement: Development Workflow
304
+
305
+ The system SHALL support efficient local development and testing of Workers deployment.
306
+
307
+ #### Scenario: Local Workers testing
308
+
309
+ - **WHEN** developer runs `npm run dev:worker`
310
+ - **THEN** wrangler starts local server with hot reload
311
+
312
+ #### Scenario: Quick iteration
313
+
314
+ - **WHEN** developer modifies `src/worker.ts`
315
+ - **THEN** wrangler automatically rebuilds and reloads
316
+
317
+ #### Scenario: curl testing
318
+
319
+ - **WHEN** developer sends curl request to local Workers
320
+ - **THEN** response matches expected MCP protocol format
321
+
322
+ ---
323
+
324
+ ### Requirement: Monitoring and Debugging
325
+
326
+ The system SHALL provide access to Workers logs and metrics for troubleshooting.
327
+
328
+ #### Scenario: Real-time logs
329
+
330
+ - **WHEN** developer runs `wrangler tail`
331
+ - **THEN** console.log output from worker appears in terminal
332
+
333
+ #### Scenario: Error logs
334
+
335
+ - **WHEN** worker throws exception
336
+ - **THEN** stack trace appears in `wrangler tail` output
337
+
338
+ #### Scenario: Cloudflare dashboard
339
+
340
+ - **WHEN** user accesses Cloudflare Workers dashboard
341
+ - **THEN** metrics show request count, error rate, and CPU time
342
+
343
+ ---
344
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aborruso/ckan-mcp-server",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "MCP server for interacting with CKAN open data portals",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",