mcp-chilegob-dataset 0.2.1 → 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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # mcp-chilegob-dataset
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/mcp-chilegob-dataset.svg)](https://www.npmjs.com/package/mcp-chilegob-dataset)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./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 above."
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
- - **Sin caché**Cada llamada hace una solicitud en vivo a datos.gob.cl. No hay límites de tasa documentados.
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 response = await fetch(url.toString());
8
- if (!response.ok) {
9
- throw new Error(`CKAN API error: ${response.status} ${response.statusText}`);
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
- const data = await response.json();
12
- if (!data.success) {
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
- return ckanAction('package_show', { id });
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 Error(`FORMAT_NOT_PARSEABLE:${normalizedFormat}:${url}`);
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: '1.0.0',
10
+ version,
8
11
  });
9
12
  registerSearchTool(server);
10
13
  registerDatasetTool(server);
package/dist/stdio.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { StdioServerTransport } from '@modelcontextprotocol/server';
2
3
  import { server } from './server.js';
3
4
  const transport = new StdioServerTransport();
@@ -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 dsMessage = datastoreError instanceof Error ? datastoreError.message : String(datastoreError);
34
- const isNoDatastore = dsMessage.toLowerCase().includes('datastore') ||
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 (fileMessage.startsWith('FORMAT_NOT_PARSEABLE:')) {
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: fmt,
76
- url,
77
- message: `This resource is a ${fmt} file and cannot be parsed automatically. Download it directly from the URL above.`,
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.2.1",
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
- "@cfworker/json-schema": "^4.1.1",
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 response = await fetch(url.toString())
47
- if (!response.ok) {
48
- throw new Error(`CKAN API error: ${response.status} ${response.statusText}`)
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
- const data = await response.json() as { success: boolean; result: T; error?: { message: string } }
52
- if (!data.success) {
53
- throw new Error(`CKAN error: ${data.error?.message ?? 'Unknown error'}`)
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
- return data.result
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
- return ckanAction<CkanDataset>('package_show', { id })
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 Error(
96
- `FORMAT_NOT_PARSEABLE:${normalizedFormat}:${url}`
97
- )
170
+ throw new NotParseableError(normalizedFormat, url)
98
171
  }
99
172
 
100
- const response = await fetch(url)
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: '1.0.0',
12
+ version,
9
13
  })
10
14
 
11
15
  registerSearchTool(server)
package/src/stdio.ts CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { StdioServerTransport } from '@modelcontextprotocol/server'
2
3
  import { server } from './server.js'
3
4
 
@@ -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
- dsMessage.toLowerCase().includes('datastore') ||
42
- dsMessage.includes('404') ||
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 (fileMessage.startsWith('FORMAT_NOT_PARSEABLE:')) {
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: fmt,
86
- url,
87
- message: `This resource is a ${fmt} file and cannot be parsed automatically. Download it directly from the URL above.`,
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,