mcp-chilegob-dataset 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/dist/ckan.js +98 -10
- package/dist/server.js +4 -1
- package/dist/stdio.js +1 -0
- package/dist/tools/resource.js +9 -11
- package/package.json +2 -3
- package/src/ckan.ts +99 -14
- package/src/server.ts +5 -1
- package/src/stdio.ts +1 -0
- package/src/tools/resource.ts +10 -12
package/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# mcp-chilegob-dataset
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/mcp-chilegob-dataset)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
> **English summary:** MCP server that exposes Chile's open government data portal ([datos.gob.cl](https://datos.gob.cl)) as tools for AI assistants. Search and read thousands of public datasets from Chilean government institutions — health, education, transport, environment, and more. No API key required.
|
|
7
|
+
|
|
3
8
|
Servidor MCP que expone el portal de datos abiertos del gobierno de Chile — [datos.gob.cl](https://datos.gob.cl) — como herramientas para asistentes de inteligencia artificial.
|
|
4
9
|
|
|
5
10
|
Construido con [Hono](https://hono.dev) y el [SDK de TypeScript del Model Context Protocol](https://github.com/modelcontextprotocol/typescript-sdk).
|
|
@@ -180,7 +185,7 @@ Lee filas tabulares de un recurso CKAN. Intenta primero el datastore y, si no es
|
|
|
180
185
|
"parseable": false,
|
|
181
186
|
"format": "XLS",
|
|
182
187
|
"url": "https://datosabiertos.mineduc.cl/archivo.xls",
|
|
183
|
-
"message": "This resource is a XLS file and cannot be parsed automatically. Download it directly from the URL
|
|
188
|
+
"message": "This resource is a XLS file and cannot be parsed automatically. Download it directly from the URL provided."
|
|
184
189
|
}
|
|
185
190
|
```
|
|
186
191
|
|
|
@@ -279,7 +284,8 @@ export function registerTuHerramienta(server: McpServer): void {
|
|
|
279
284
|
- **Disponibilidad del datastore** — No todos los recursos tienen datastore habilitado en CKAN. `get_resource_data` intenta automáticamente descargar el archivo (CSV, TSV, JSON); los formatos binarios (XLS, PDF) requieren descarga manual desde la URL devuelta.
|
|
280
285
|
- **Archivos grandes** — La descarga directa carga el archivo completo en memoria antes de paginar. Para archivos muy grandes (>100 MB) esto puede ser lento o fallar.
|
|
281
286
|
- **Encoding** — Los archivos CSV de datos.gob.cl pueden estar en ISO-8859-1 (Latin-1). La herramienta intenta leerlos como UTF-8; si los caracteres aparecen corruptos, descarga el archivo directamente.
|
|
282
|
-
- **
|
|
287
|
+
- **Caché en memoria (5 min)** — `search_datasets` y `get_dataset` usan caché en memoria con TTL de 5 minutos. `get_resource_data` siempre consulta en vivo. No hay límites de tasa documentados en datos.gob.cl.
|
|
288
|
+
- **Timeout de red (10s)** — Todas las solicitudes a datos.gob.cl tienen un timeout de 10 segundos. Si el portal está lento o caído, las herramientas devuelven un error claro en lugar de colgar indefinidamente.
|
|
283
289
|
- **Paquetes en alpha** — `@modelcontextprotocol/hono` y `@modelcontextprotocol/server` están en versión alpha.
|
|
284
290
|
|
|
285
291
|
---
|
|
@@ -290,7 +296,6 @@ Las contribuciones son bienvenidas. Algunas ideas:
|
|
|
290
296
|
|
|
291
297
|
- [ ] Herramienta `list_organizations` — listar instituciones disponibles
|
|
292
298
|
- [ ] Herramienta `get_resource_schema` — tipos y descripciones de columnas
|
|
293
|
-
- [ ] Caché en memoria para reducir llamadas a la API
|
|
294
299
|
- [ ] MCP Resources con URI templates (`datos-gob-cl://dataset/{id}`)
|
|
295
300
|
|
|
296
301
|
Por favor, abre un issue antes de enviar un PR grande.
|
package/dist/ckan.js
CHANGED
|
@@ -1,25 +1,99 @@
|
|
|
1
1
|
const CKAN_BASE = 'https://datos.gob.cl/api/3/action';
|
|
2
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
3
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
4
|
+
class TTLCache {
|
|
5
|
+
store = new Map();
|
|
6
|
+
get(key) {
|
|
7
|
+
const entry = this.store.get(key);
|
|
8
|
+
if (entry === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (Date.now() > entry.expiresAt) {
|
|
11
|
+
this.store.delete(key);
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
return entry.value;
|
|
15
|
+
}
|
|
16
|
+
set(key, value, ttlMs) {
|
|
17
|
+
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export class NotParseableError extends Error {
|
|
21
|
+
format;
|
|
22
|
+
url;
|
|
23
|
+
constructor(format, url) {
|
|
24
|
+
super(`Format not parseable: ${format} (${url})`);
|
|
25
|
+
this.format = format;
|
|
26
|
+
this.url = url;
|
|
27
|
+
this.name = 'NotParseableError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class CkanHttpError extends Error {
|
|
31
|
+
statusCode;
|
|
32
|
+
statusText;
|
|
33
|
+
constructor(statusCode, statusText) {
|
|
34
|
+
super(`CKAN HTTP error: ${statusCode} ${statusText}`);
|
|
35
|
+
this.statusCode = statusCode;
|
|
36
|
+
this.statusText = statusText;
|
|
37
|
+
this.name = 'CkanHttpError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export class CkanApiError extends Error {
|
|
41
|
+
errorType;
|
|
42
|
+
constructor(message, errorType) {
|
|
43
|
+
super(`CKAN API error: ${message}`);
|
|
44
|
+
this.errorType = errorType;
|
|
45
|
+
this.name = 'CkanApiError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
2
48
|
async function ckanAction(action, params) {
|
|
3
49
|
const url = new URL(`${CKAN_BASE}/${action}`);
|
|
4
50
|
for (const [key, value] of Object.entries(params)) {
|
|
5
51
|
url.searchParams.set(key, String(value));
|
|
6
52
|
}
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(url.toString(), { signal: controller.signal });
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new CkanHttpError(response.status, response.statusText);
|
|
59
|
+
}
|
|
60
|
+
const data = await response.json();
|
|
61
|
+
if (!data.success) {
|
|
62
|
+
const errorType = data.error?.__type ?? 'Unknown Error';
|
|
63
|
+
const message = data.error?.message ?? 'Unknown error';
|
|
64
|
+
throw new CkanApiError(message, errorType);
|
|
65
|
+
}
|
|
66
|
+
return data.result;
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
70
|
+
throw new Error(`Request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
|
|
71
|
+
}
|
|
72
|
+
throw err;
|
|
10
73
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
throw new Error(`CKAN error: ${data.error?.message ?? 'Unknown error'}`);
|
|
74
|
+
finally {
|
|
75
|
+
clearTimeout(timer);
|
|
14
76
|
}
|
|
15
|
-
return data.result;
|
|
16
77
|
}
|
|
78
|
+
const searchCache = new TTLCache();
|
|
79
|
+
const datasetCache = new TTLCache();
|
|
17
80
|
export async function searchDatasets(query, limit = 10) {
|
|
81
|
+
const key = `search:${query}:${limit}`;
|
|
82
|
+
const cached = searchCache.get(key);
|
|
83
|
+
if (cached !== undefined)
|
|
84
|
+
return cached;
|
|
18
85
|
const result = await ckanAction('package_search', { q: query, rows: limit });
|
|
86
|
+
searchCache.set(key, result.results, CACHE_TTL_MS);
|
|
19
87
|
return result.results;
|
|
20
88
|
}
|
|
21
89
|
export async function getDataset(id) {
|
|
22
|
-
|
|
90
|
+
const key = `dataset:${id}`;
|
|
91
|
+
const cached = datasetCache.get(key);
|
|
92
|
+
if (cached !== undefined)
|
|
93
|
+
return cached;
|
|
94
|
+
const dataset = await ckanAction('package_show', { id });
|
|
95
|
+
datasetCache.set(key, dataset, CACHE_TTL_MS);
|
|
96
|
+
return dataset;
|
|
23
97
|
}
|
|
24
98
|
export async function getResourceData(resourceId, limit = 50, offset = 0) {
|
|
25
99
|
return ckanAction('datastore_search', {
|
|
@@ -35,9 +109,23 @@ const PARSEABLE_FORMATS = new Set(['CSV', 'TSV', 'JSON']);
|
|
|
35
109
|
export async function fetchAndParseFile(url, format, limit, offset) {
|
|
36
110
|
const normalizedFormat = format.toUpperCase().trim();
|
|
37
111
|
if (!PARSEABLE_FORMATS.has(normalizedFormat)) {
|
|
38
|
-
throw new
|
|
112
|
+
throw new NotParseableError(normalizedFormat, url);
|
|
113
|
+
}
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
116
|
+
let response;
|
|
117
|
+
try {
|
|
118
|
+
response = await fetch(url, { signal: controller.signal });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
122
|
+
throw new Error(`Request timed out after ${FETCH_TIMEOUT_MS / 1000}s`);
|
|
123
|
+
}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
clearTimeout(timer);
|
|
39
128
|
}
|
|
40
|
-
const response = await fetch(url);
|
|
41
129
|
if (!response.ok) {
|
|
42
130
|
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
|
|
43
131
|
}
|
package/dist/server.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
3
|
import { registerSearchTool } from './tools/search.js';
|
|
3
4
|
import { registerDatasetTool } from './tools/dataset.js';
|
|
4
5
|
import { registerResourceTool } from './tools/resource.js';
|
|
6
|
+
const _require = createRequire(import.meta.url);
|
|
7
|
+
const { version } = _require('../package.json');
|
|
5
8
|
export const server = new McpServer({
|
|
6
9
|
name: 'datos-gob-cl',
|
|
7
|
-
version
|
|
10
|
+
version,
|
|
8
11
|
});
|
|
9
12
|
registerSearchTool(server);
|
|
10
13
|
registerDatasetTool(server);
|
package/dist/stdio.js
CHANGED
package/dist/tools/resource.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { getResourceData, getResource, fetchAndParseFile } from '../ckan.js';
|
|
2
|
+
import { getResourceData, getResource, fetchAndParseFile, NotParseableError, CkanHttpError, CkanApiError } from '../ckan.js';
|
|
3
3
|
export function registerResourceTool(server) {
|
|
4
4
|
server.registerTool('get_resource_data', {
|
|
5
5
|
title: 'Get Resource Data',
|
|
@@ -30,11 +30,10 @@ export function registerResourceTool(server) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
catch (datastoreError) {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
dsMessage.includes('404') ||
|
|
36
|
-
dsMessage.toUpperCase().includes('NOT FOUND');
|
|
33
|
+
const isNoDatastore = (datastoreError instanceof CkanHttpError && datastoreError.statusCode === 404) ||
|
|
34
|
+
(datastoreError instanceof CkanApiError && datastoreError.errorType.toLowerCase().includes('not found'));
|
|
37
35
|
if (!isNoDatastore) {
|
|
36
|
+
const dsMessage = datastoreError instanceof Error ? datastoreError.message : String(datastoreError);
|
|
38
37
|
return {
|
|
39
38
|
content: [{ type: 'text', text: `Error: ${dsMessage}` }],
|
|
40
39
|
isError: true,
|
|
@@ -62,23 +61,22 @@ export function registerResourceTool(server) {
|
|
|
62
61
|
};
|
|
63
62
|
}
|
|
64
63
|
catch (fileError) {
|
|
65
|
-
const fileMessage = fileError instanceof Error ? fileError.message : String(fileError);
|
|
66
64
|
// Format not parseable — return the URL so the AI can guide the user
|
|
67
|
-
if (
|
|
68
|
-
const [, fmt, url] = fileMessage.split(':');
|
|
65
|
+
if (fileError instanceof NotParseableError) {
|
|
69
66
|
return {
|
|
70
67
|
content: [{
|
|
71
68
|
type: 'text',
|
|
72
69
|
text: JSON.stringify({
|
|
73
70
|
source: 'file',
|
|
74
71
|
parseable: false,
|
|
75
|
-
format:
|
|
76
|
-
url,
|
|
77
|
-
message: `This resource is a ${
|
|
72
|
+
format: fileError.format,
|
|
73
|
+
url: fileError.url,
|
|
74
|
+
message: `This resource is a ${fileError.format} file and cannot be parsed automatically. Download it directly from the URL above.`,
|
|
78
75
|
}, null, 2),
|
|
79
76
|
}],
|
|
80
77
|
};
|
|
81
78
|
}
|
|
79
|
+
const fileMessage = fileError instanceof Error ? fileError.message : String(fileError);
|
|
82
80
|
return {
|
|
83
81
|
content: [{ type: 'text', text: `Error reading file: ${fileMessage}` }],
|
|
84
82
|
isError: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-chilegob-dataset",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server exposing Chile's open government dataset portal (datos.gob.cl / CKAN API v3)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,8 +37,7 @@
|
|
|
37
37
|
"prepublishOnly": "tsc"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
|
|
41
|
-
"@hono/node-server": "^1.19.13",
|
|
40
|
+
"@hono/node-server": "^1.19.13",
|
|
42
41
|
"@modelcontextprotocol/hono": "^2.0.0-alpha.2",
|
|
43
42
|
"@modelcontextprotocol/server": "^2.0.0-alpha.2",
|
|
44
43
|
"hono": "^4.12.12",
|
package/src/ckan.ts
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
const CKAN_BASE = 'https://datos.gob.cl/api/3/action'
|
|
2
|
+
const FETCH_TIMEOUT_MS = 10_000
|
|
3
|
+
const CACHE_TTL_MS = 5 * 60 * 1000
|
|
4
|
+
|
|
5
|
+
class TTLCache<V> {
|
|
6
|
+
private readonly store = new Map<string, { value: V; expiresAt: number }>()
|
|
7
|
+
|
|
8
|
+
get(key: string): V | undefined {
|
|
9
|
+
const entry = this.store.get(key)
|
|
10
|
+
if (entry === undefined) return undefined
|
|
11
|
+
if (Date.now() > entry.expiresAt) {
|
|
12
|
+
this.store.delete(key)
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
15
|
+
return entry.value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
set(key: string, value: V, ttlMs: number): void {
|
|
19
|
+
this.store.set(key, { value, expiresAt: Date.now() + ttlMs })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class NotParseableError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
public readonly format: string,
|
|
26
|
+
public readonly url: string,
|
|
27
|
+
) {
|
|
28
|
+
super(`Format not parseable: ${format} (${url})`)
|
|
29
|
+
this.name = 'NotParseableError'
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class CkanHttpError extends Error {
|
|
34
|
+
constructor(
|
|
35
|
+
public readonly statusCode: number,
|
|
36
|
+
public readonly statusText: string,
|
|
37
|
+
) {
|
|
38
|
+
super(`CKAN HTTP error: ${statusCode} ${statusText}`)
|
|
39
|
+
this.name = 'CkanHttpError'
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class CkanApiError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
message: string,
|
|
46
|
+
public readonly errorType: string,
|
|
47
|
+
) {
|
|
48
|
+
super(`CKAN API error: ${message}`)
|
|
49
|
+
this.name = 'CkanApiError'
|
|
50
|
+
}
|
|
51
|
+
}
|
|
2
52
|
|
|
3
53
|
export interface CkanDataset {
|
|
4
54
|
id: string
|
|
@@ -43,26 +93,51 @@ async function ckanAction<T>(action: string, params: Record<string, unknown>): P
|
|
|
43
93
|
url.searchParams.set(key, String(value))
|
|
44
94
|
}
|
|
45
95
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
96
|
+
const controller = new AbortController()
|
|
97
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch(url.toString(), { signal: controller.signal })
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
throw new CkanHttpError(response.status, response.statusText)
|
|
102
|
+
}
|
|
50
103
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
104
|
+
const data = await response.json() as { success: boolean; result: T; error?: { __type: string; message?: string } }
|
|
105
|
+
if (!data.success) {
|
|
106
|
+
const errorType = data.error?.__type ?? 'Unknown Error'
|
|
107
|
+
const message = data.error?.message ?? 'Unknown error'
|
|
108
|
+
throw new CkanApiError(message, errorType)
|
|
109
|
+
}
|
|
55
110
|
|
|
56
|
-
|
|
111
|
+
return data.result
|
|
112
|
+
} catch (err) {
|
|
113
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
114
|
+
throw new Error(`Request timed out after ${FETCH_TIMEOUT_MS / 1000}s`)
|
|
115
|
+
}
|
|
116
|
+
throw err
|
|
117
|
+
} finally {
|
|
118
|
+
clearTimeout(timer)
|
|
119
|
+
}
|
|
57
120
|
}
|
|
58
121
|
|
|
122
|
+
const searchCache = new TTLCache<CkanDataset[]>()
|
|
123
|
+
const datasetCache = new TTLCache<CkanDataset>()
|
|
124
|
+
|
|
59
125
|
export async function searchDatasets(query: string, limit: number = 10): Promise<CkanDataset[]> {
|
|
126
|
+
const key = `search:${query}:${limit}`
|
|
127
|
+
const cached = searchCache.get(key)
|
|
128
|
+
if (cached !== undefined) return cached
|
|
60
129
|
const result = await ckanAction<{ results: CkanDataset[] }>('package_search', { q: query, rows: limit })
|
|
130
|
+
searchCache.set(key, result.results, CACHE_TTL_MS)
|
|
61
131
|
return result.results
|
|
62
132
|
}
|
|
63
133
|
|
|
64
134
|
export async function getDataset(id: string): Promise<CkanDataset> {
|
|
65
|
-
|
|
135
|
+
const key = `dataset:${id}`
|
|
136
|
+
const cached = datasetCache.get(key)
|
|
137
|
+
if (cached !== undefined) return cached
|
|
138
|
+
const dataset = await ckanAction<CkanDataset>('package_show', { id })
|
|
139
|
+
datasetCache.set(key, dataset, CACHE_TTL_MS)
|
|
140
|
+
return dataset
|
|
66
141
|
}
|
|
67
142
|
|
|
68
143
|
export async function getResourceData(
|
|
@@ -92,12 +167,22 @@ export async function fetchAndParseFile(
|
|
|
92
167
|
const normalizedFormat = format.toUpperCase().trim()
|
|
93
168
|
|
|
94
169
|
if (!PARSEABLE_FORMATS.has(normalizedFormat)) {
|
|
95
|
-
throw new
|
|
96
|
-
`FORMAT_NOT_PARSEABLE:${normalizedFormat}:${url}`
|
|
97
|
-
)
|
|
170
|
+
throw new NotParseableError(normalizedFormat, url)
|
|
98
171
|
}
|
|
99
172
|
|
|
100
|
-
const
|
|
173
|
+
const controller = new AbortController()
|
|
174
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
175
|
+
let response: Response
|
|
176
|
+
try {
|
|
177
|
+
response = await fetch(url, { signal: controller.signal })
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
180
|
+
throw new Error(`Request timed out after ${FETCH_TIMEOUT_MS / 1000}s`)
|
|
181
|
+
}
|
|
182
|
+
throw err
|
|
183
|
+
} finally {
|
|
184
|
+
clearTimeout(timer)
|
|
185
|
+
}
|
|
101
186
|
if (!response.ok) {
|
|
102
187
|
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
|
|
103
188
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
1
2
|
import { McpServer } from '@modelcontextprotocol/server'
|
|
2
3
|
import { registerSearchTool } from './tools/search.js'
|
|
3
4
|
import { registerDatasetTool } from './tools/dataset.js'
|
|
4
5
|
import { registerResourceTool } from './tools/resource.js'
|
|
5
6
|
|
|
7
|
+
const _require = createRequire(import.meta.url)
|
|
8
|
+
const { version } = _require('../package.json') as { version: string }
|
|
9
|
+
|
|
6
10
|
export const server = new McpServer({
|
|
7
11
|
name: 'datos-gob-cl',
|
|
8
|
-
version
|
|
12
|
+
version,
|
|
9
13
|
})
|
|
10
14
|
|
|
11
15
|
registerSearchTool(server)
|
package/src/stdio.ts
CHANGED
package/src/tools/resource.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
-
import { getResourceData, getResource, fetchAndParseFile } from '../ckan.js'
|
|
3
|
+
import { getResourceData, getResource, fetchAndParseFile, NotParseableError, CkanHttpError, CkanApiError } from '../ckan.js'
|
|
4
4
|
|
|
5
5
|
export function registerResourceTool(server: McpServer): void {
|
|
6
6
|
server.registerTool(
|
|
@@ -36,13 +36,12 @@ export function registerResourceTool(server: McpServer): void {
|
|
|
36
36
|
}],
|
|
37
37
|
}
|
|
38
38
|
} catch (datastoreError) {
|
|
39
|
-
const dsMessage = datastoreError instanceof Error ? datastoreError.message : String(datastoreError)
|
|
40
39
|
const isNoDatastore =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
dsMessage.toUpperCase().includes('NOT FOUND')
|
|
40
|
+
(datastoreError instanceof CkanHttpError && datastoreError.statusCode === 404) ||
|
|
41
|
+
(datastoreError instanceof CkanApiError && datastoreError.errorType.toLowerCase().includes('not found'))
|
|
44
42
|
|
|
45
43
|
if (!isNoDatastore) {
|
|
44
|
+
const dsMessage = datastoreError instanceof Error ? datastoreError.message : String(datastoreError)
|
|
46
45
|
return {
|
|
47
46
|
content: [{ type: 'text', text: `Error: ${dsMessage}` }],
|
|
48
47
|
isError: true,
|
|
@@ -71,25 +70,24 @@ export function registerResourceTool(server: McpServer): void {
|
|
|
71
70
|
}],
|
|
72
71
|
}
|
|
73
72
|
} catch (fileError) {
|
|
74
|
-
const fileMessage = fileError instanceof Error ? fileError.message : String(fileError)
|
|
75
|
-
|
|
76
73
|
// Format not parseable — return the URL so the AI can guide the user
|
|
77
|
-
if (
|
|
78
|
-
const [, fmt, url] = fileMessage.split(':')
|
|
74
|
+
if (fileError instanceof NotParseableError) {
|
|
79
75
|
return {
|
|
80
76
|
content: [{
|
|
81
77
|
type: 'text',
|
|
82
78
|
text: JSON.stringify({
|
|
83
79
|
source: 'file',
|
|
84
80
|
parseable: false,
|
|
85
|
-
format:
|
|
86
|
-
url,
|
|
87
|
-
message: `This resource is a ${
|
|
81
|
+
format: fileError.format,
|
|
82
|
+
url: fileError.url,
|
|
83
|
+
message: `This resource is a ${fileError.format} file and cannot be parsed automatically. Download it directly from the URL above.`,
|
|
88
84
|
}, null, 2),
|
|
89
85
|
}],
|
|
90
86
|
}
|
|
91
87
|
}
|
|
92
88
|
|
|
89
|
+
const fileMessage = fileError instanceof Error ? fileError.message : String(fileError)
|
|
90
|
+
|
|
93
91
|
return {
|
|
94
92
|
content: [{ type: 'text', text: `Error reading file: ${fileMessage}` }],
|
|
95
93
|
isError: true,
|