mcp-chilegob-dataset 0.1.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/LICENSE +21 -0
- package/README.md +262 -0
- package/dist/ckan.js +30 -0
- package/dist/index.js +13 -0
- package/dist/server.js +11 -0
- package/dist/stdio.js +4 -0
- package/dist/tools/dataset.js +43 -0
- package/dist/tools/resource.js +42 -0
- package/dist/tools/search.js +32 -0
- package/package.json +52 -0
- package/src/ckan.ts +67 -0
- package/src/index.ts +19 -0
- package/src/server.ts +13 -0
- package/src/stdio.ts +5 -0
- package/src/tools/dataset.ts +48 -0
- package/src/tools/resource.ts +47 -0
- package/src/tools/search.ts +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Gerard Bourguett
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# mcp-chilegob-dataset
|
|
2
|
+
|
|
3
|
+
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
|
+
|
|
5
|
+
Construido con [Hono](https://hono.dev) y el [SDK de TypeScript del Model Context Protocol](https://github.com/modelcontextprotocol/typescript-sdk).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## ¿Qué es MCP?
|
|
10
|
+
|
|
11
|
+
El **Model Context Protocol (MCP)** es un estándar abierto que permite a los asistentes de IA (Claude, etc.) conectarse a herramientas y fuentes de datos externas de manera estructurada y segura. En lugar de copiar datos crudos en una conversación, el asistente llama a una herramienta y recibe resultados estructurados.
|
|
12
|
+
|
|
13
|
+
## ¿Qué es datos.gob.cl?
|
|
14
|
+
|
|
15
|
+
[datos.gob.cl](https://datos.gob.cl) es el portal oficial de datos abiertos del gobierno de Chile, impulsado por [CKAN](https://ckan.org). Contiene miles de datasets públicos de instituciones gubernamentales — salud, educación, transporte, medio ambiente, y más. Todos los datos son de acceso público, sin registro ni autenticación.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Instalación y uso rápido
|
|
20
|
+
|
|
21
|
+
### Con Claude Desktop (recomendado)
|
|
22
|
+
|
|
23
|
+
**Paso 1** — Abrí la configuración de Claude Desktop.
|
|
24
|
+
|
|
25
|
+
En Mac: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
26
|
+
En Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
27
|
+
|
|
28
|
+
**Paso 2** — Agregá estas líneas:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"mcpServers": {
|
|
33
|
+
"chilegob": {
|
|
34
|
+
"command": "npx",
|
|
35
|
+
"args": ["-y", "mcp-chilegob-dataset"]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Paso 3** — Reiniciá Claude Desktop. La primera vez descarga el paquete automáticamente.
|
|
42
|
+
|
|
43
|
+
Eso es todo. Podés pedirle a Claude cosas como:
|
|
44
|
+
|
|
45
|
+
> *"Buscá datasets sobre educación en datos.gob.cl"*
|
|
46
|
+
> *"Mostrá los datos del dataset de matrícula universitaria"*
|
|
47
|
+
> *"¿Qué datasets de salud hay disponibles?"*
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Herramientas disponibles
|
|
52
|
+
|
|
53
|
+
Este servidor expone tres herramientas que el asistente puede usar automáticamente.
|
|
54
|
+
|
|
55
|
+
### `search_datasets`
|
|
56
|
+
|
|
57
|
+
Busca datasets en datos.gob.cl por palabra clave. Devuelve una lista resumida.
|
|
58
|
+
|
|
59
|
+
**Parámetros:**
|
|
60
|
+
|
|
61
|
+
| Nombre | Tipo | Requerido | Por defecto | Descripción |
|
|
62
|
+
|--------|------|-----------|-------------|-------------|
|
|
63
|
+
| `query` | string | ✅ | — | Texto de búsqueda (en español o inglés) |
|
|
64
|
+
| `limit` | number | ❌ | 10 | Máximo de resultados (1–100) |
|
|
65
|
+
|
|
66
|
+
**Ejemplo de respuesta:**
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
[
|
|
70
|
+
{
|
|
71
|
+
"id": "matricula-en-educacion-superior",
|
|
72
|
+
"title": "Matrícula en Educación Superior",
|
|
73
|
+
"description": "Bases de datos de matrícula en el sistema de educación superior...",
|
|
74
|
+
"organization": "Subsecretaría de Educación",
|
|
75
|
+
"resource_count": 1
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### `get_dataset`
|
|
83
|
+
|
|
84
|
+
Obtiene los metadatos completos de un dataset: descripción, recursos, etiquetas y licencia.
|
|
85
|
+
|
|
86
|
+
**Parámetros:**
|
|
87
|
+
|
|
88
|
+
| Nombre | Tipo | Requerido | Descripción |
|
|
89
|
+
|--------|------|-----------|-------------|
|
|
90
|
+
| `id` | string | ✅ | Slug o UUID del dataset (obtenido de `search_datasets`) |
|
|
91
|
+
|
|
92
|
+
**Ejemplo de respuesta:**
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"id": "matricula-en-educacion-superior",
|
|
97
|
+
"title": "Matrícula en Educación Superior",
|
|
98
|
+
"organization": "Subsecretaría de Educación",
|
|
99
|
+
"license": "Creative Commons Non-Commercial (Any)",
|
|
100
|
+
"tags": ["educación", "educación superior", "matriculas"],
|
|
101
|
+
"resources": [
|
|
102
|
+
{
|
|
103
|
+
"id": "37377aff-6df8-4424-9228-2f44f3e67fcd",
|
|
104
|
+
"name": "Base de datos de matrícula",
|
|
105
|
+
"format": "CSV",
|
|
106
|
+
"url": "https://datosabiertos.mineduc.cl/...",
|
|
107
|
+
"datastore_available": true
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
> Verificá el campo `datastore_available` antes de usar `get_resource_data`. Los recursos sin datastore deben descargarse desde su `url`.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
### `get_resource_data`
|
|
118
|
+
|
|
119
|
+
Lee filas tabulares de un recurso con el datastore CKAN habilitado. Soporta paginación.
|
|
120
|
+
|
|
121
|
+
**Parámetros:**
|
|
122
|
+
|
|
123
|
+
| Nombre | Tipo | Requerido | Por defecto | Descripción |
|
|
124
|
+
|--------|------|-----------|-------------|-------------|
|
|
125
|
+
| `resource_id` | string | ✅ | — | UUID del recurso (obtenido de `get_dataset`) |
|
|
126
|
+
| `limit` | number | ❌ | 50 | Filas a devolver (1–500) |
|
|
127
|
+
| `offset` | number | ❌ | 0 | Desplazamiento para paginación |
|
|
128
|
+
|
|
129
|
+
**Ejemplo de respuesta:**
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"total": 42,
|
|
134
|
+
"returned": 3,
|
|
135
|
+
"offset": 0,
|
|
136
|
+
"fields": [
|
|
137
|
+
{ "id": "Region", "type": "text" },
|
|
138
|
+
{ "id": "Provincia", "type": "text" },
|
|
139
|
+
{ "id": "Comuna", "type": "text" }
|
|
140
|
+
],
|
|
141
|
+
"records": [
|
|
142
|
+
{ "Region": "COQUIMBO", "Provincia": "ELQUI", "Comuna": "LA SERENA" }
|
|
143
|
+
]
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
> Si el recurso no tiene datastore habilitado, la herramienta devuelve un mensaje descriptivo indicando cómo acceder al archivo directamente.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Para desarrolladores
|
|
152
|
+
|
|
153
|
+
### Requisitos
|
|
154
|
+
|
|
155
|
+
- **Node.js 20 o superior**
|
|
156
|
+
- **npm**
|
|
157
|
+
|
|
158
|
+
### Instalación desde el código fuente
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
git clone https://github.com/gerardbourguett/mcp-chilegob-dataset.git
|
|
162
|
+
cd mcp-chilegob-dataset
|
|
163
|
+
npm install
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Scripts disponibles
|
|
167
|
+
|
|
168
|
+
| Comando | Descripción |
|
|
169
|
+
|---------|-------------|
|
|
170
|
+
| `npm run dev` | Inicia el servidor HTTP en `http://localhost:3000/mcp` con recarga automática |
|
|
171
|
+
| `npm run build` | Compila TypeScript a `dist/` |
|
|
172
|
+
| `npm start` | Inicia el servidor HTTP desde `dist/` |
|
|
173
|
+
| `npm run typecheck` | Verifica tipos sin compilar |
|
|
174
|
+
|
|
175
|
+
### Probar localmente (MCP Inspector)
|
|
176
|
+
|
|
177
|
+
Con el servidor corriendo (`npm run dev`), abrí otra terminal y ejecutá:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
npx @modelcontextprotocol/inspector
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Se abre una interfaz web en `http://localhost:6274`. Configurá:
|
|
184
|
+
- **Transport type**: `Streamable HTTP`
|
|
185
|
+
- **URL**: `http://localhost:3000/mcp`
|
|
186
|
+
|
|
187
|
+
Desde ahí podés llamar a cualquier herramienta de forma interactiva y ver las respuestas.
|
|
188
|
+
|
|
189
|
+
### Arquitectura
|
|
190
|
+
|
|
191
|
+
```
|
|
192
|
+
Cliente AI → Hono HTTP → McpServer → CKAN API v3 (datos.gob.cl)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
src/
|
|
197
|
+
├── index.ts # Servidor HTTP (para desarrollo y despliegue remoto)
|
|
198
|
+
├── stdio.ts # Transporte stdio (para npx y Claude Desktop)
|
|
199
|
+
├── server.ts # Instancia McpServer + registro de herramientas
|
|
200
|
+
├── ckan.ts # Cliente tipado para la API CKAN v3
|
|
201
|
+
└── tools/
|
|
202
|
+
├── search.ts # search_datasets
|
|
203
|
+
├── dataset.ts # get_dataset
|
|
204
|
+
└── resource.ts # get_resource_data
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Cómo agregar nuevas herramientas
|
|
208
|
+
|
|
209
|
+
1. Creá `src/tools/tu-herramienta.ts`
|
|
210
|
+
2. Importala y registrala en `src/server.ts`
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
// src/tools/tu-herramienta.ts
|
|
214
|
+
import type { McpServer } from '@modelcontextprotocol/server'
|
|
215
|
+
import { z } from 'zod'
|
|
216
|
+
|
|
217
|
+
export function registerTuHerramienta(server: McpServer): void {
|
|
218
|
+
server.registerTool(
|
|
219
|
+
'nombre_herramienta',
|
|
220
|
+
{
|
|
221
|
+
title: 'Nombre visible en el cliente',
|
|
222
|
+
description: 'Descripción clara. El asistente la usa para decidir cuándo llamar esta herramienta.',
|
|
223
|
+
inputSchema: z.object({
|
|
224
|
+
parametro: z.string().describe('Descripción del parámetro'),
|
|
225
|
+
}),
|
|
226
|
+
},
|
|
227
|
+
async ({ parametro }) => {
|
|
228
|
+
// tu lógica acá
|
|
229
|
+
return {
|
|
230
|
+
content: [{ type: 'text', text: JSON.stringify({ resultado: parametro }) }],
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Limitaciones conocidas
|
|
240
|
+
|
|
241
|
+
- **Disponibilidad del datastore** — No todos los recursos tienen datastore habilitado en CKAN. Los archivos (CSV, XLS, PDF) deben descargarse desde su URL directa.
|
|
242
|
+
- **Sin caché** — Cada llamada hace una solicitud en vivo a datos.gob.cl. No hay límites de tasa documentados.
|
|
243
|
+
- **Paquetes en alpha** — `@modelcontextprotocol/hono` y `@modelcontextprotocol/server` están en versión alpha.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Contribuciones
|
|
248
|
+
|
|
249
|
+
Las contribuciones son bienvenidas. Algunas ideas:
|
|
250
|
+
|
|
251
|
+
- [ ] Herramienta `list_organizations` — listar instituciones disponibles
|
|
252
|
+
- [ ] Herramienta `get_resource_schema` — tipos y descripciones de columnas
|
|
253
|
+
- [ ] Caché en memoria para reducir llamadas a la API
|
|
254
|
+
- [ ] MCP Resources con URI templates (`datos-gob-cl://dataset/{id}`)
|
|
255
|
+
|
|
256
|
+
Por favor, abrí un issue antes de enviar un PR grande.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Licencia
|
|
261
|
+
|
|
262
|
+
MIT — ver [LICENSE](./LICENSE).
|
package/dist/ckan.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const CKAN_BASE = 'https://datos.gob.cl/api/3/action';
|
|
2
|
+
async function ckanAction(action, params) {
|
|
3
|
+
const url = new URL(`${CKAN_BASE}/${action}`);
|
|
4
|
+
for (const [key, value] of Object.entries(params)) {
|
|
5
|
+
url.searchParams.set(key, String(value));
|
|
6
|
+
}
|
|
7
|
+
const response = await fetch(url.toString());
|
|
8
|
+
if (!response.ok) {
|
|
9
|
+
throw new Error(`CKAN API error: ${response.status} ${response.statusText}`);
|
|
10
|
+
}
|
|
11
|
+
const data = await response.json();
|
|
12
|
+
if (!data.success) {
|
|
13
|
+
throw new Error(`CKAN error: ${data.error?.message ?? 'Unknown error'}`);
|
|
14
|
+
}
|
|
15
|
+
return data.result;
|
|
16
|
+
}
|
|
17
|
+
export async function searchDatasets(query, limit = 10) {
|
|
18
|
+
const result = await ckanAction('package_search', { q: query, rows: limit });
|
|
19
|
+
return result.results;
|
|
20
|
+
}
|
|
21
|
+
export async function getDataset(id) {
|
|
22
|
+
return ckanAction('package_show', { id });
|
|
23
|
+
}
|
|
24
|
+
export async function getResourceData(resourceId, limit = 50, offset = 0) {
|
|
25
|
+
return ckanAction('datastore_search', {
|
|
26
|
+
resource_id: resourceId,
|
|
27
|
+
limit,
|
|
28
|
+
offset,
|
|
29
|
+
});
|
|
30
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { serve } from '@hono/node-server';
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
|
3
|
+
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
|
|
4
|
+
import { server } from './server.js';
|
|
5
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
6
|
+
sessionIdGenerator: undefined,
|
|
7
|
+
});
|
|
8
|
+
await server.connect(transport);
|
|
9
|
+
const app = createMcpHonoApp();
|
|
10
|
+
app.all('/mcp', c => transport.handleRequest(c.req.raw, { parsedBody: c.get('parsedBody') }));
|
|
11
|
+
serve({ fetch: app.fetch, port: 3000 }, info => {
|
|
12
|
+
console.log(`MCP server running on http://localhost:${info.port}/mcp`);
|
|
13
|
+
});
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server';
|
|
2
|
+
import { registerSearchTool } from './tools/search.js';
|
|
3
|
+
import { registerDatasetTool } from './tools/dataset.js';
|
|
4
|
+
import { registerResourceTool } from './tools/resource.js';
|
|
5
|
+
export const server = new McpServer({
|
|
6
|
+
name: 'datos-gob-cl',
|
|
7
|
+
version: '1.0.0',
|
|
8
|
+
});
|
|
9
|
+
registerSearchTool(server);
|
|
10
|
+
registerDatasetTool(server);
|
|
11
|
+
registerResourceTool(server);
|
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getDataset } from '../ckan.js';
|
|
3
|
+
export function registerDatasetTool(server) {
|
|
4
|
+
server.registerTool('get_dataset', {
|
|
5
|
+
title: 'Get Dataset',
|
|
6
|
+
description: 'Get full metadata for a dataset from datos.gob.cl by its ID or slug. Use search_datasets first to find the ID.',
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
id: z.string().describe('Dataset slug or UUID (e.g., "nombre-del-dataset" or a UUID from search results)'),
|
|
9
|
+
}),
|
|
10
|
+
}, async ({ id }) => {
|
|
11
|
+
try {
|
|
12
|
+
const dataset = await getDataset(id);
|
|
13
|
+
return {
|
|
14
|
+
content: [{
|
|
15
|
+
type: 'text',
|
|
16
|
+
text: JSON.stringify({
|
|
17
|
+
id: dataset.name,
|
|
18
|
+
title: dataset.title,
|
|
19
|
+
description: dataset.notes,
|
|
20
|
+
organization: dataset.organization?.title ?? null,
|
|
21
|
+
license: dataset.license_title,
|
|
22
|
+
tags: dataset.tags.map(t => t.name),
|
|
23
|
+
resources: dataset.resources.map(r => ({
|
|
24
|
+
id: r.id,
|
|
25
|
+
name: r.name,
|
|
26
|
+
format: r.format,
|
|
27
|
+
url: r.url,
|
|
28
|
+
datastore_available: r.datastore_active,
|
|
29
|
+
})),
|
|
30
|
+
}, null, 2),
|
|
31
|
+
}],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
36
|
+
const isNotFound = message.toLowerCase().includes('not found');
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: 'text', text: isNotFound ? `Dataset not found: ${id}` : `Error: ${message}` }],
|
|
39
|
+
isError: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getResourceData } from '../ckan.js';
|
|
3
|
+
export function registerResourceTool(server) {
|
|
4
|
+
server.registerTool('get_resource_data', {
|
|
5
|
+
title: 'Get Resource Data',
|
|
6
|
+
description: 'Read tabular data from a CKAN resource. Only works for resources with datastore enabled (check datastore_available from get_dataset). Supports pagination via limit and offset.',
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
resource_id: z.string().describe('Resource UUID from a dataset\'s resources list (use get_dataset to obtain it)'),
|
|
9
|
+
limit: z.number().int().min(1).max(500).default(50).optional().describe('Rows to return (default: 50, max: 500)'),
|
|
10
|
+
offset: z.number().int().min(0).default(0).optional().describe('Row offset for pagination (default: 0)'),
|
|
11
|
+
}),
|
|
12
|
+
}, async ({ resource_id, limit, offset }) => {
|
|
13
|
+
try {
|
|
14
|
+
const result = await getResourceData(resource_id, limit ?? 50, offset ?? 0);
|
|
15
|
+
return {
|
|
16
|
+
content: [{
|
|
17
|
+
type: 'text',
|
|
18
|
+
text: JSON.stringify({
|
|
19
|
+
total: result.total,
|
|
20
|
+
returned: result.records.length,
|
|
21
|
+
offset: offset ?? 0,
|
|
22
|
+
fields: result.fields,
|
|
23
|
+
records: result.records,
|
|
24
|
+
}, null, 2),
|
|
25
|
+
}],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
30
|
+
const isNoDatastore = message.toLowerCase().includes('datastore') || message.includes('404') || message.includes('NOT FOUND');
|
|
31
|
+
return {
|
|
32
|
+
content: [{
|
|
33
|
+
type: 'text',
|
|
34
|
+
text: isNoDatastore
|
|
35
|
+
? `Datastore not available for resource "${resource_id}". This resource may be a file (CSV, XLS, PDF) without an activated datastore. Use the resource URL from get_dataset to download it directly.`
|
|
36
|
+
: `Error: ${message}`,
|
|
37
|
+
}],
|
|
38
|
+
isError: true,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { searchDatasets } from '../ckan.js';
|
|
3
|
+
export function registerSearchTool(server) {
|
|
4
|
+
server.registerTool('search_datasets', {
|
|
5
|
+
title: 'Search Datasets',
|
|
6
|
+
description: 'Search for datasets in the Chilean government open data portal (datos.gob.cl). Returns a list of matching datasets with their IDs, titles, and resource counts.',
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
query: z.string().describe('Search query in Spanish or English (e.g., "educación", "salud", "transporte")'),
|
|
9
|
+
limit: z.number().int().min(1).max(100).default(10).optional().describe('Max number of results (default: 10, max: 100)'),
|
|
10
|
+
}),
|
|
11
|
+
}, async ({ query, limit }) => {
|
|
12
|
+
try {
|
|
13
|
+
const datasets = await searchDatasets(query, limit ?? 10);
|
|
14
|
+
const formatted = datasets.map(d => ({
|
|
15
|
+
id: d.name,
|
|
16
|
+
title: d.title,
|
|
17
|
+
description: d.notes?.slice(0, 200) ?? '',
|
|
18
|
+
organization: d.organization?.title ?? 'N/A',
|
|
19
|
+
resource_count: d.num_resources,
|
|
20
|
+
}));
|
|
21
|
+
return {
|
|
22
|
+
content: [{ type: 'text', text: JSON.stringify(formatted, null, 2) }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
|
|
28
|
+
isError: true,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-chilegob-dataset",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server exposing Chile's open government dataset portal (datos.gob.cl / CKAN API v3)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/gerardbourguett/mcp-chilegob-dataset.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"mcp",
|
|
13
|
+
"model-context-protocol",
|
|
14
|
+
"chile",
|
|
15
|
+
"open-data",
|
|
16
|
+
"datos-gob-cl",
|
|
17
|
+
"ckan",
|
|
18
|
+
"hono"
|
|
19
|
+
],
|
|
20
|
+
"bin": {
|
|
21
|
+
"mcp-chilegob-dataset": "./dist/stdio.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist/",
|
|
25
|
+
"src/",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"dev": "tsx watch src/index.ts",
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"start": "node dist/index.js",
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"prepublishOnly": "tsc"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@cfworker/json-schema": "^4.1.1",
|
|
41
|
+
"@hono/node-server": "^1.19.13",
|
|
42
|
+
"@modelcontextprotocol/hono": "^2.0.0-alpha.2",
|
|
43
|
+
"@modelcontextprotocol/server": "^2.0.0-alpha.2",
|
|
44
|
+
"hono": "^4.12.12",
|
|
45
|
+
"zod": "^4.3.6"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^20.11.17",
|
|
49
|
+
"tsx": "^4.7.1",
|
|
50
|
+
"typescript": "^5.8.3"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/ckan.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const CKAN_BASE = 'https://datos.gob.cl/api/3/action'
|
|
2
|
+
|
|
3
|
+
export interface CkanDataset {
|
|
4
|
+
id: string
|
|
5
|
+
name: string
|
|
6
|
+
title: string
|
|
7
|
+
notes: string
|
|
8
|
+
organization: { title: string } | null
|
|
9
|
+
resources: CkanResource[]
|
|
10
|
+
tags: { name: string }[]
|
|
11
|
+
license_title: string
|
|
12
|
+
num_resources: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface CkanResource {
|
|
16
|
+
id: string
|
|
17
|
+
name: string
|
|
18
|
+
format: string
|
|
19
|
+
url: string
|
|
20
|
+
datastore_active: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CkanDatastoreResult {
|
|
24
|
+
fields: { id: string; type: string }[]
|
|
25
|
+
records: Record<string, unknown>[]
|
|
26
|
+
total: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function ckanAction<T>(action: string, params: Record<string, unknown>): Promise<T> {
|
|
30
|
+
const url = new URL(`${CKAN_BASE}/${action}`)
|
|
31
|
+
for (const [key, value] of Object.entries(params)) {
|
|
32
|
+
url.searchParams.set(key, String(value))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const response = await fetch(url.toString())
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`CKAN API error: ${response.status} ${response.statusText}`)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const data = await response.json() as { success: boolean; result: T; error?: { message: string } }
|
|
41
|
+
if (!data.success) {
|
|
42
|
+
throw new Error(`CKAN error: ${data.error?.message ?? 'Unknown error'}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return data.result
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function searchDatasets(query: string, limit: number = 10): Promise<CkanDataset[]> {
|
|
49
|
+
const result = await ckanAction<{ results: CkanDataset[] }>('package_search', { q: query, rows: limit })
|
|
50
|
+
return result.results
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function getDataset(id: string): Promise<CkanDataset> {
|
|
54
|
+
return ckanAction<CkanDataset>('package_show', { id })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function getResourceData(
|
|
58
|
+
resourceId: string,
|
|
59
|
+
limit: number = 50,
|
|
60
|
+
offset: number = 0
|
|
61
|
+
): Promise<CkanDatastoreResult> {
|
|
62
|
+
return ckanAction<CkanDatastoreResult>('datastore_search', {
|
|
63
|
+
resource_id: resourceId,
|
|
64
|
+
limit,
|
|
65
|
+
offset,
|
|
66
|
+
})
|
|
67
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { serve } from '@hono/node-server'
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'
|
|
3
|
+
import { createMcpHonoApp } from '@modelcontextprotocol/hono'
|
|
4
|
+
import type { Hono } from 'hono'
|
|
5
|
+
import { server } from './server.js'
|
|
6
|
+
|
|
7
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
8
|
+
sessionIdGenerator: undefined,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
await server.connect(transport)
|
|
12
|
+
|
|
13
|
+
type McpVars = { Variables: { parsedBody: unknown } }
|
|
14
|
+
const app = createMcpHonoApp() as unknown as Hono<McpVars>
|
|
15
|
+
app.all('/mcp', c => transport.handleRequest(c.req.raw, { parsedBody: c.get('parsedBody') }))
|
|
16
|
+
|
|
17
|
+
serve({ fetch: app.fetch, port: 3000 }, info => {
|
|
18
|
+
console.log(`MCP server running on http://localhost:${info.port}/mcp`)
|
|
19
|
+
})
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/server'
|
|
2
|
+
import { registerSearchTool } from './tools/search.js'
|
|
3
|
+
import { registerDatasetTool } from './tools/dataset.js'
|
|
4
|
+
import { registerResourceTool } from './tools/resource.js'
|
|
5
|
+
|
|
6
|
+
export const server = new McpServer({
|
|
7
|
+
name: 'datos-gob-cl',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
registerSearchTool(server)
|
|
12
|
+
registerDatasetTool(server)
|
|
13
|
+
registerResourceTool(server)
|
package/src/stdio.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { getDataset } from '../ckan.js'
|
|
4
|
+
|
|
5
|
+
export function registerDatasetTool(server: McpServer): void {
|
|
6
|
+
server.registerTool(
|
|
7
|
+
'get_dataset',
|
|
8
|
+
{
|
|
9
|
+
title: 'Get Dataset',
|
|
10
|
+
description: 'Get full metadata for a dataset from datos.gob.cl by its ID or slug. Use search_datasets first to find the ID.',
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
id: z.string().describe('Dataset slug or UUID (e.g., "nombre-del-dataset" or a UUID from search results)'),
|
|
13
|
+
}),
|
|
14
|
+
},
|
|
15
|
+
async ({ id }) => {
|
|
16
|
+
try {
|
|
17
|
+
const dataset = await getDataset(id)
|
|
18
|
+
return {
|
|
19
|
+
content: [{
|
|
20
|
+
type: 'text',
|
|
21
|
+
text: JSON.stringify({
|
|
22
|
+
id: dataset.name,
|
|
23
|
+
title: dataset.title,
|
|
24
|
+
description: dataset.notes,
|
|
25
|
+
organization: dataset.organization?.title ?? null,
|
|
26
|
+
license: dataset.license_title,
|
|
27
|
+
tags: dataset.tags.map(t => t.name),
|
|
28
|
+
resources: dataset.resources.map(r => ({
|
|
29
|
+
id: r.id,
|
|
30
|
+
name: r.name,
|
|
31
|
+
format: r.format,
|
|
32
|
+
url: r.url,
|
|
33
|
+
datastore_available: r.datastore_active,
|
|
34
|
+
})),
|
|
35
|
+
}, null, 2),
|
|
36
|
+
}],
|
|
37
|
+
}
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
40
|
+
const isNotFound = message.toLowerCase().includes('not found')
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: 'text', text: isNotFound ? `Dataset not found: ${id}` : `Error: ${message}` }],
|
|
43
|
+
isError: true,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { getResourceData } from '../ckan.js'
|
|
4
|
+
|
|
5
|
+
export function registerResourceTool(server: McpServer): void {
|
|
6
|
+
server.registerTool(
|
|
7
|
+
'get_resource_data',
|
|
8
|
+
{
|
|
9
|
+
title: 'Get Resource Data',
|
|
10
|
+
description: 'Read tabular data from a CKAN resource. Only works for resources with datastore enabled (check datastore_available from get_dataset). Supports pagination via limit and offset.',
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
resource_id: z.string().describe('Resource UUID from a dataset\'s resources list (use get_dataset to obtain it)'),
|
|
13
|
+
limit: z.number().int().min(1).max(500).default(50).optional().describe('Rows to return (default: 50, max: 500)'),
|
|
14
|
+
offset: z.number().int().min(0).default(0).optional().describe('Row offset for pagination (default: 0)'),
|
|
15
|
+
}),
|
|
16
|
+
},
|
|
17
|
+
async ({ resource_id, limit, offset }) => {
|
|
18
|
+
try {
|
|
19
|
+
const result = await getResourceData(resource_id, limit ?? 50, offset ?? 0)
|
|
20
|
+
return {
|
|
21
|
+
content: [{
|
|
22
|
+
type: 'text',
|
|
23
|
+
text: JSON.stringify({
|
|
24
|
+
total: result.total,
|
|
25
|
+
returned: result.records.length,
|
|
26
|
+
offset: offset ?? 0,
|
|
27
|
+
fields: result.fields,
|
|
28
|
+
records: result.records,
|
|
29
|
+
}, null, 2),
|
|
30
|
+
}],
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
34
|
+
const isNoDatastore = message.toLowerCase().includes('datastore') || message.includes('404') || message.includes('NOT FOUND')
|
|
35
|
+
return {
|
|
36
|
+
content: [{
|
|
37
|
+
type: 'text',
|
|
38
|
+
text: isNoDatastore
|
|
39
|
+
? `Datastore not available for resource "${resource_id}". This resource may be a file (CSV, XLS, PDF) without an activated datastore. Use the resource URL from get_dataset to download it directly.`
|
|
40
|
+
: `Error: ${message}`,
|
|
41
|
+
}],
|
|
42
|
+
isError: true,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { searchDatasets } from '../ckan.js'
|
|
4
|
+
|
|
5
|
+
export function registerSearchTool(server: McpServer): void {
|
|
6
|
+
server.registerTool(
|
|
7
|
+
'search_datasets',
|
|
8
|
+
{
|
|
9
|
+
title: 'Search Datasets',
|
|
10
|
+
description: 'Search for datasets in the Chilean government open data portal (datos.gob.cl). Returns a list of matching datasets with their IDs, titles, and resource counts.',
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
query: z.string().describe('Search query in Spanish or English (e.g., "educación", "salud", "transporte")'),
|
|
13
|
+
limit: z.number().int().min(1).max(100).default(10).optional().describe('Max number of results (default: 10, max: 100)'),
|
|
14
|
+
}),
|
|
15
|
+
},
|
|
16
|
+
async ({ query, limit }) => {
|
|
17
|
+
try {
|
|
18
|
+
const datasets = await searchDatasets(query, limit ?? 10)
|
|
19
|
+
const formatted = datasets.map(d => ({
|
|
20
|
+
id: d.name,
|
|
21
|
+
title: d.title,
|
|
22
|
+
description: d.notes?.slice(0, 200) ?? '',
|
|
23
|
+
organization: d.organization?.title ?? 'N/A',
|
|
24
|
+
resource_count: d.num_resources,
|
|
25
|
+
}))
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: 'text', text: JSON.stringify(formatted, null, 2) }],
|
|
28
|
+
}
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return {
|
|
31
|
+
content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
|
|
32
|
+
isError: true,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
}
|