mcp-chilegob-dataset 0.1.0 → 0.2.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 +58 -18
- package/dist/ckan.js +67 -0
- package/dist/tools/resource.js +57 -11
- package/package.json +1 -1
- package/src/ckan.ts +98 -0
- package/src/tools/resource.ts +64 -11
package/README.md
CHANGED
|
@@ -20,12 +20,12 @@ El **Model Context Protocol (MCP)** es un estándar abierto que permite a los as
|
|
|
20
20
|
|
|
21
21
|
### Con Claude Desktop (recomendado)
|
|
22
22
|
|
|
23
|
-
**Paso 1** —
|
|
23
|
+
**Paso 1** — Abre la configuración de Claude Desktop.
|
|
24
24
|
|
|
25
25
|
En Mac: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
26
26
|
En Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
27
27
|
|
|
28
|
-
**Paso 2** —
|
|
28
|
+
**Paso 2** — Agrega estas líneas:
|
|
29
29
|
|
|
30
30
|
```json
|
|
31
31
|
{
|
|
@@ -38,12 +38,12 @@ En Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
|
38
38
|
}
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
**Paso 3** —
|
|
41
|
+
**Paso 3** — Reinicia Claude Desktop. La primera vez descarga el paquete automáticamente.
|
|
42
42
|
|
|
43
|
-
Eso es todo.
|
|
43
|
+
Eso es todo. Puedes pedirle a Claude cosas como:
|
|
44
44
|
|
|
45
|
-
> *"
|
|
46
|
-
> *"
|
|
45
|
+
> *"Busca datasets sobre educación en datos.gob.cl"*
|
|
46
|
+
> *"Muestra los datos del dataset de matrícula universitaria"*
|
|
47
47
|
> *"¿Qué datasets de salud hay disponibles?"*
|
|
48
48
|
|
|
49
49
|
---
|
|
@@ -110,13 +110,21 @@ Obtiene los metadatos completos de un dataset: descripción, recursos, etiquetas
|
|
|
110
110
|
}
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
-
>
|
|
113
|
+
> Verifica el campo `datastore_available` antes de usar `get_resource_data`. Los recursos sin datastore deben descargarse desde su `url`.
|
|
114
114
|
|
|
115
115
|
---
|
|
116
116
|
|
|
117
117
|
### `get_resource_data`
|
|
118
118
|
|
|
119
|
-
Lee filas tabulares de un recurso
|
|
119
|
+
Lee filas tabulares de un recurso CKAN. Intenta primero el datastore y, si no está disponible, descarga y parsea el archivo directamente. Soporta paginación.
|
|
120
|
+
|
|
121
|
+
**Estrategia de obtención de datos (automática):**
|
|
122
|
+
|
|
123
|
+
1. **Datastore CKAN** — acceso estructurado y rápido; disponible solo en algunos recursos.
|
|
124
|
+
2. **Descarga directa** — si el datastore no está habilitado, la herramienta descarga el archivo y lo parsea en memoria:
|
|
125
|
+
- `CSV` / `TSV` — parseado nativo, devuelve filas y columnas.
|
|
126
|
+
- `JSON` — parseado nativo si el contenido es un array de objetos.
|
|
127
|
+
- `XLS`, `PDF` y otros formatos binarios — no se parsean; se devuelve la URL directa para que el usuario los descargue.
|
|
120
128
|
|
|
121
129
|
**Parámetros:**
|
|
122
130
|
|
|
@@ -126,10 +134,11 @@ Lee filas tabulares de un recurso con el datastore CKAN habilitado. Soporta pagi
|
|
|
126
134
|
| `limit` | number | ❌ | 50 | Filas a devolver (1–500) |
|
|
127
135
|
| `offset` | number | ❌ | 0 | Desplazamiento para paginación |
|
|
128
136
|
|
|
129
|
-
**Ejemplo de respuesta:**
|
|
137
|
+
**Ejemplo de respuesta (datastore):**
|
|
130
138
|
|
|
131
139
|
```json
|
|
132
140
|
{
|
|
141
|
+
"source": "datastore",
|
|
133
142
|
"total": 42,
|
|
134
143
|
"returned": 3,
|
|
135
144
|
"offset": 0,
|
|
@@ -144,7 +153,36 @@ Lee filas tabulares de un recurso con el datastore CKAN habilitado. Soporta pagi
|
|
|
144
153
|
}
|
|
145
154
|
```
|
|
146
155
|
|
|
147
|
-
|
|
156
|
+
**Ejemplo de respuesta (archivo CSV descargado):**
|
|
157
|
+
|
|
158
|
+
```json
|
|
159
|
+
{
|
|
160
|
+
"source": "file",
|
|
161
|
+
"format": "CSV",
|
|
162
|
+
"url": "https://datosabiertos.mineduc.cl/...",
|
|
163
|
+
"total": 1500,
|
|
164
|
+
"returned": 50,
|
|
165
|
+
"offset": 0,
|
|
166
|
+
"fields": [
|
|
167
|
+
{ "id": "Region", "type": "text" }
|
|
168
|
+
],
|
|
169
|
+
"records": [
|
|
170
|
+
{ "Region": "METROPOLITANA" }
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
**Ejemplo de respuesta (formato no parseable):**
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"source": "file",
|
|
180
|
+
"parseable": false,
|
|
181
|
+
"format": "XLS",
|
|
182
|
+
"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."
|
|
184
|
+
}
|
|
185
|
+
```
|
|
148
186
|
|
|
149
187
|
---
|
|
150
188
|
|
|
@@ -174,17 +212,17 @@ npm install
|
|
|
174
212
|
|
|
175
213
|
### Probar localmente (MCP Inspector)
|
|
176
214
|
|
|
177
|
-
Con el servidor corriendo (`npm run dev`),
|
|
215
|
+
Con el servidor corriendo (`npm run dev`), abre otra terminal y ejecuta:
|
|
178
216
|
|
|
179
217
|
```bash
|
|
180
218
|
npx @modelcontextprotocol/inspector
|
|
181
219
|
```
|
|
182
220
|
|
|
183
|
-
Se abre una interfaz web en `http://localhost:6274`.
|
|
221
|
+
Se abre una interfaz web en `http://localhost:6274`. Configura:
|
|
184
222
|
- **Transport type**: `Streamable HTTP`
|
|
185
223
|
- **URL**: `http://localhost:3000/mcp`
|
|
186
224
|
|
|
187
|
-
Desde ahí
|
|
225
|
+
Desde ahí puedes llamar a cualquier herramienta de forma interactiva y ver las respuestas.
|
|
188
226
|
|
|
189
227
|
### Arquitectura
|
|
190
228
|
|
|
@@ -206,8 +244,8 @@ src/
|
|
|
206
244
|
|
|
207
245
|
### Cómo agregar nuevas herramientas
|
|
208
246
|
|
|
209
|
-
1.
|
|
210
|
-
2.
|
|
247
|
+
1. Crea `src/tools/tu-herramienta.ts`
|
|
248
|
+
2. Impórtala y regístrala en `src/server.ts`
|
|
211
249
|
|
|
212
250
|
```typescript
|
|
213
251
|
// src/tools/tu-herramienta.ts
|
|
@@ -225,7 +263,7 @@ export function registerTuHerramienta(server: McpServer): void {
|
|
|
225
263
|
}),
|
|
226
264
|
},
|
|
227
265
|
async ({ parametro }) => {
|
|
228
|
-
// tu lógica
|
|
266
|
+
// tu lógica aquí
|
|
229
267
|
return {
|
|
230
268
|
content: [{ type: 'text', text: JSON.stringify({ resultado: parametro }) }],
|
|
231
269
|
}
|
|
@@ -238,7 +276,9 @@ export function registerTuHerramienta(server: McpServer): void {
|
|
|
238
276
|
|
|
239
277
|
## Limitaciones conocidas
|
|
240
278
|
|
|
241
|
-
- **Disponibilidad del datastore** — No todos los recursos tienen datastore habilitado en CKAN.
|
|
279
|
+
- **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
|
+
- **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
|
+
- **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.
|
|
242
282
|
- **Sin caché** — Cada llamada hace una solicitud en vivo a datos.gob.cl. No hay límites de tasa documentados.
|
|
243
283
|
- **Paquetes en alpha** — `@modelcontextprotocol/hono` y `@modelcontextprotocol/server` están en versión alpha.
|
|
244
284
|
|
|
@@ -253,7 +293,7 @@ Las contribuciones son bienvenidas. Algunas ideas:
|
|
|
253
293
|
- [ ] Caché en memoria para reducir llamadas a la API
|
|
254
294
|
- [ ] MCP Resources con URI templates (`datos-gob-cl://dataset/{id}`)
|
|
255
295
|
|
|
256
|
-
Por favor,
|
|
296
|
+
Por favor, abre un issue antes de enviar un PR grande.
|
|
257
297
|
|
|
258
298
|
---
|
|
259
299
|
|
package/dist/ckan.js
CHANGED
|
@@ -28,3 +28,70 @@ export async function getResourceData(resourceId, limit = 50, offset = 0) {
|
|
|
28
28
|
offset,
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
export async function getResource(resourceId) {
|
|
32
|
+
return ckanAction('resource_show', { id: resourceId });
|
|
33
|
+
}
|
|
34
|
+
const PARSEABLE_FORMATS = new Set(['CSV', 'TSV', 'JSON']);
|
|
35
|
+
export async function fetchAndParseFile(url, format, limit, offset) {
|
|
36
|
+
const normalizedFormat = format.toUpperCase().trim();
|
|
37
|
+
if (!PARSEABLE_FORMATS.has(normalizedFormat)) {
|
|
38
|
+
throw new Error(`FORMAT_NOT_PARSEABLE:${normalizedFormat}:${url}`);
|
|
39
|
+
}
|
|
40
|
+
const response = await fetch(url);
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`);
|
|
43
|
+
}
|
|
44
|
+
if (normalizedFormat === 'JSON') {
|
|
45
|
+
const json = await response.json();
|
|
46
|
+
const rows = Array.isArray(json)
|
|
47
|
+
? json
|
|
48
|
+
: [{ data: json }];
|
|
49
|
+
const page = rows.slice(offset, offset + limit);
|
|
50
|
+
const fields = page.length > 0
|
|
51
|
+
? Object.keys(page[0]).map(key => ({ id: key, type: 'text' }))
|
|
52
|
+
: [];
|
|
53
|
+
return { fields, records: page, total: rows.length, source: 'file' };
|
|
54
|
+
}
|
|
55
|
+
// CSV / TSV
|
|
56
|
+
const text = await response.text();
|
|
57
|
+
const separator = normalizedFormat === 'TSV' ? '\t' : ',';
|
|
58
|
+
const lines = text.split(/\r?\n/).filter(l => l.trim() !== '');
|
|
59
|
+
if (lines.length === 0) {
|
|
60
|
+
return { fields: [], records: [], total: 0, source: 'file' };
|
|
61
|
+
}
|
|
62
|
+
const headers = parseDelimitedLine(lines[0], separator);
|
|
63
|
+
const dataLines = lines.slice(1);
|
|
64
|
+
const page = dataLines.slice(offset, offset + limit);
|
|
65
|
+
const records = page.map(line => {
|
|
66
|
+
const values = parseDelimitedLine(line, separator);
|
|
67
|
+
return Object.fromEntries(headers.map((h, i) => [h, values[i] ?? '']));
|
|
68
|
+
});
|
|
69
|
+
const fields = headers.map(h => ({ id: h, type: 'text' }));
|
|
70
|
+
return { fields, records, total: dataLines.length, source: 'file' };
|
|
71
|
+
}
|
|
72
|
+
function parseDelimitedLine(line, separator) {
|
|
73
|
+
const result = [];
|
|
74
|
+
let current = '';
|
|
75
|
+
let inQuotes = false;
|
|
76
|
+
for (let i = 0; i < line.length; i++) {
|
|
77
|
+
const char = line[i];
|
|
78
|
+
if (char === '"') {
|
|
79
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
80
|
+
current += '"';
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
inQuotes = !inQuotes;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (char === separator && !inQuotes) {
|
|
88
|
+
result.push(current);
|
|
89
|
+
current = '';
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
current += char;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
result.push(current);
|
|
96
|
+
return result;
|
|
97
|
+
}
|
package/dist/tools/resource.js
CHANGED
|
@@ -1,40 +1,86 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { getResourceData } from '../ckan.js';
|
|
2
|
+
import { getResourceData, getResource, fetchAndParseFile } from '../ckan.js';
|
|
3
3
|
export function registerResourceTool(server) {
|
|
4
4
|
server.registerTool('get_resource_data', {
|
|
5
5
|
title: 'Get Resource Data',
|
|
6
|
-
description: 'Read tabular data from a CKAN resource.
|
|
6
|
+
description: 'Read tabular data from a CKAN resource. Tries the CKAN datastore first; if unavailable, automatically downloads and parses the file (CSV, TSV, JSON). For XLS, PDF and other binary formats it returns the direct download URL. Supports pagination via limit and offset.',
|
|
7
7
|
inputSchema: z.object({
|
|
8
|
-
resource_id: z.string().describe(
|
|
8
|
+
resource_id: z.string().describe("Resource UUID from a dataset's resources list (use get_dataset to obtain it)"),
|
|
9
9
|
limit: z.number().int().min(1).max(500).default(50).optional().describe('Rows to return (default: 50, max: 500)'),
|
|
10
10
|
offset: z.number().int().min(0).default(0).optional().describe('Row offset for pagination (default: 0)'),
|
|
11
11
|
}),
|
|
12
12
|
}, async ({ resource_id, limit, offset }) => {
|
|
13
|
+
const effectiveLimit = limit ?? 50;
|
|
14
|
+
const effectiveOffset = offset ?? 0;
|
|
15
|
+
// Attempt 1: CKAN datastore
|
|
13
16
|
try {
|
|
14
|
-
const result = await getResourceData(resource_id,
|
|
17
|
+
const result = await getResourceData(resource_id, effectiveLimit, effectiveOffset);
|
|
15
18
|
return {
|
|
16
19
|
content: [{
|
|
17
20
|
type: 'text',
|
|
18
21
|
text: JSON.stringify({
|
|
22
|
+
source: 'datastore',
|
|
19
23
|
total: result.total,
|
|
20
24
|
returned: result.records.length,
|
|
21
|
-
offset:
|
|
25
|
+
offset: effectiveOffset,
|
|
22
26
|
fields: result.fields,
|
|
23
27
|
records: result.records,
|
|
24
28
|
}, null, 2),
|
|
25
29
|
}],
|
|
26
30
|
};
|
|
27
31
|
}
|
|
28
|
-
catch (
|
|
29
|
-
const
|
|
30
|
-
const isNoDatastore =
|
|
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');
|
|
37
|
+
if (!isNoDatastore) {
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: 'text', text: `Error: ${dsMessage}` }],
|
|
40
|
+
isError: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Attempt 2: direct file download
|
|
45
|
+
try {
|
|
46
|
+
const resource = await getResource(resource_id);
|
|
47
|
+
const result = await fetchAndParseFile(resource.url, resource.format, effectiveLimit, effectiveOffset);
|
|
31
48
|
return {
|
|
32
49
|
content: [{
|
|
33
50
|
type: 'text',
|
|
34
|
-
text:
|
|
35
|
-
|
|
36
|
-
:
|
|
51
|
+
text: JSON.stringify({
|
|
52
|
+
source: 'file',
|
|
53
|
+
format: resource.format,
|
|
54
|
+
url: resource.url,
|
|
55
|
+
total: result.total,
|
|
56
|
+
returned: result.records.length,
|
|
57
|
+
offset: effectiveOffset,
|
|
58
|
+
fields: result.fields,
|
|
59
|
+
records: result.records,
|
|
60
|
+
}, null, 2),
|
|
37
61
|
}],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (fileError) {
|
|
65
|
+
const fileMessage = fileError instanceof Error ? fileError.message : String(fileError);
|
|
66
|
+
// 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(':');
|
|
69
|
+
return {
|
|
70
|
+
content: [{
|
|
71
|
+
type: 'text',
|
|
72
|
+
text: JSON.stringify({
|
|
73
|
+
source: 'file',
|
|
74
|
+
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.`,
|
|
78
|
+
}, null, 2),
|
|
79
|
+
}],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: 'text', text: `Error reading file: ${fileMessage}` }],
|
|
38
84
|
isError: true,
|
|
39
85
|
};
|
|
40
86
|
}
|
package/package.json
CHANGED
package/src/ckan.ts
CHANGED
|
@@ -20,10 +20,21 @@ export interface CkanResource {
|
|
|
20
20
|
datastore_active: boolean
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export interface CkanResourceDetail {
|
|
24
|
+
id: string
|
|
25
|
+
name: string
|
|
26
|
+
format: string
|
|
27
|
+
url: string
|
|
28
|
+
datastore_active: boolean
|
|
29
|
+
mimetype: string | null
|
|
30
|
+
size: number | null
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
export interface CkanDatastoreResult {
|
|
24
34
|
fields: { id: string; type: string }[]
|
|
25
35
|
records: Record<string, unknown>[]
|
|
26
36
|
total: number
|
|
37
|
+
source?: 'datastore' | 'file'
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
async function ckanAction<T>(action: string, params: Record<string, unknown>): Promise<T> {
|
|
@@ -65,3 +76,90 @@ export async function getResourceData(
|
|
|
65
76
|
offset,
|
|
66
77
|
})
|
|
67
78
|
}
|
|
79
|
+
|
|
80
|
+
export async function getResource(resourceId: string): Promise<CkanResourceDetail> {
|
|
81
|
+
return ckanAction<CkanResourceDetail>('resource_show', { id: resourceId })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const PARSEABLE_FORMATS = new Set(['CSV', 'TSV', 'JSON'])
|
|
85
|
+
|
|
86
|
+
export async function fetchAndParseFile(
|
|
87
|
+
url: string,
|
|
88
|
+
format: string,
|
|
89
|
+
limit: number,
|
|
90
|
+
offset: number
|
|
91
|
+
): Promise<CkanDatastoreResult> {
|
|
92
|
+
const normalizedFormat = format.toUpperCase().trim()
|
|
93
|
+
|
|
94
|
+
if (!PARSEABLE_FORMATS.has(normalizedFormat)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`FORMAT_NOT_PARSEABLE:${normalizedFormat}:${url}`
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const response = await fetch(url)
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (normalizedFormat === 'JSON') {
|
|
106
|
+
const json = await response.json() as unknown
|
|
107
|
+
const rows: Record<string, unknown>[] = Array.isArray(json)
|
|
108
|
+
? (json as Record<string, unknown>[])
|
|
109
|
+
: [{ data: json }]
|
|
110
|
+
|
|
111
|
+
const page = rows.slice(offset, offset + limit)
|
|
112
|
+
const fields = page.length > 0
|
|
113
|
+
? Object.keys(page[0]).map(key => ({ id: key, type: 'text' }))
|
|
114
|
+
: []
|
|
115
|
+
|
|
116
|
+
return { fields, records: page, total: rows.length, source: 'file' }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// CSV / TSV
|
|
120
|
+
const text = await response.text()
|
|
121
|
+
const separator = normalizedFormat === 'TSV' ? '\t' : ','
|
|
122
|
+
const lines = text.split(/\r?\n/).filter(l => l.trim() !== '')
|
|
123
|
+
|
|
124
|
+
if (lines.length === 0) {
|
|
125
|
+
return { fields: [], records: [], total: 0, source: 'file' }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const headers = parseDelimitedLine(lines[0], separator)
|
|
129
|
+
const dataLines = lines.slice(1)
|
|
130
|
+
const page = dataLines.slice(offset, offset + limit)
|
|
131
|
+
|
|
132
|
+
const records = page.map(line => {
|
|
133
|
+
const values = parseDelimitedLine(line, separator)
|
|
134
|
+
return Object.fromEntries(headers.map((h, i) => [h, values[i] ?? '']))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const fields = headers.map(h => ({ id: h, type: 'text' }))
|
|
138
|
+
|
|
139
|
+
return { fields, records, total: dataLines.length, source: 'file' }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function parseDelimitedLine(line: string, separator: string): string[] {
|
|
143
|
+
const result: string[] = []
|
|
144
|
+
let current = ''
|
|
145
|
+
let inQuotes = false
|
|
146
|
+
|
|
147
|
+
for (let i = 0; i < line.length; i++) {
|
|
148
|
+
const char = line[i]
|
|
149
|
+
if (char === '"') {
|
|
150
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
151
|
+
current += '"'
|
|
152
|
+
i++
|
|
153
|
+
} else {
|
|
154
|
+
inQuotes = !inQuotes
|
|
155
|
+
}
|
|
156
|
+
} else if (char === separator && !inQuotes) {
|
|
157
|
+
result.push(current)
|
|
158
|
+
current = ''
|
|
159
|
+
} else {
|
|
160
|
+
current += char
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
result.push(current)
|
|
164
|
+
return result
|
|
165
|
+
}
|
package/src/tools/resource.ts
CHANGED
|
@@ -1,44 +1,97 @@
|
|
|
1
1
|
import type { McpServer } from '@modelcontextprotocol/server'
|
|
2
2
|
import { z } from 'zod'
|
|
3
|
-
import { getResourceData } from '../ckan.js'
|
|
3
|
+
import { getResourceData, getResource, fetchAndParseFile } from '../ckan.js'
|
|
4
4
|
|
|
5
5
|
export function registerResourceTool(server: McpServer): void {
|
|
6
6
|
server.registerTool(
|
|
7
7
|
'get_resource_data',
|
|
8
8
|
{
|
|
9
9
|
title: 'Get Resource Data',
|
|
10
|
-
description:
|
|
10
|
+
description:
|
|
11
|
+
'Read tabular data from a CKAN resource. Tries the CKAN datastore first; if unavailable, automatically downloads and parses the file (CSV, TSV, JSON). For XLS, PDF and other binary formats it returns the direct download URL. Supports pagination via limit and offset.',
|
|
11
12
|
inputSchema: z.object({
|
|
12
|
-
resource_id: z.string().describe(
|
|
13
|
+
resource_id: z.string().describe("Resource UUID from a dataset's resources list (use get_dataset to obtain it)"),
|
|
13
14
|
limit: z.number().int().min(1).max(500).default(50).optional().describe('Rows to return (default: 50, max: 500)'),
|
|
14
15
|
offset: z.number().int().min(0).default(0).optional().describe('Row offset for pagination (default: 0)'),
|
|
15
16
|
}),
|
|
16
17
|
},
|
|
17
18
|
async ({ resource_id, limit, offset }) => {
|
|
19
|
+
const effectiveLimit = limit ?? 50
|
|
20
|
+
const effectiveOffset = offset ?? 0
|
|
21
|
+
|
|
22
|
+
// Attempt 1: CKAN datastore
|
|
18
23
|
try {
|
|
19
|
-
const result = await getResourceData(resource_id,
|
|
24
|
+
const result = await getResourceData(resource_id, effectiveLimit, effectiveOffset)
|
|
20
25
|
return {
|
|
21
26
|
content: [{
|
|
22
27
|
type: 'text',
|
|
23
28
|
text: JSON.stringify({
|
|
29
|
+
source: 'datastore',
|
|
24
30
|
total: result.total,
|
|
25
31
|
returned: result.records.length,
|
|
26
|
-
offset:
|
|
32
|
+
offset: effectiveOffset,
|
|
27
33
|
fields: result.fields,
|
|
28
34
|
records: result.records,
|
|
29
35
|
}, null, 2),
|
|
30
36
|
}],
|
|
31
37
|
}
|
|
32
|
-
} catch (
|
|
33
|
-
const
|
|
34
|
-
const isNoDatastore =
|
|
38
|
+
} catch (datastoreError) {
|
|
39
|
+
const dsMessage = datastoreError instanceof Error ? datastoreError.message : String(datastoreError)
|
|
40
|
+
const isNoDatastore =
|
|
41
|
+
dsMessage.toLowerCase().includes('datastore') ||
|
|
42
|
+
dsMessage.includes('404') ||
|
|
43
|
+
dsMessage.toUpperCase().includes('NOT FOUND')
|
|
44
|
+
|
|
45
|
+
if (!isNoDatastore) {
|
|
46
|
+
return {
|
|
47
|
+
content: [{ type: 'text', text: `Error: ${dsMessage}` }],
|
|
48
|
+
isError: true,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Attempt 2: direct file download
|
|
54
|
+
try {
|
|
55
|
+
const resource = await getResource(resource_id)
|
|
56
|
+
const result = await fetchAndParseFile(resource.url, resource.format, effectiveLimit, effectiveOffset)
|
|
57
|
+
|
|
35
58
|
return {
|
|
36
59
|
content: [{
|
|
37
60
|
type: 'text',
|
|
38
|
-
text:
|
|
39
|
-
|
|
40
|
-
:
|
|
61
|
+
text: JSON.stringify({
|
|
62
|
+
source: 'file',
|
|
63
|
+
format: resource.format,
|
|
64
|
+
url: resource.url,
|
|
65
|
+
total: result.total,
|
|
66
|
+
returned: result.records.length,
|
|
67
|
+
offset: effectiveOffset,
|
|
68
|
+
fields: result.fields,
|
|
69
|
+
records: result.records,
|
|
70
|
+
}, null, 2),
|
|
41
71
|
}],
|
|
72
|
+
}
|
|
73
|
+
} catch (fileError) {
|
|
74
|
+
const fileMessage = fileError instanceof Error ? fileError.message : String(fileError)
|
|
75
|
+
|
|
76
|
+
// 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(':')
|
|
79
|
+
return {
|
|
80
|
+
content: [{
|
|
81
|
+
type: 'text',
|
|
82
|
+
text: JSON.stringify({
|
|
83
|
+
source: 'file',
|
|
84
|
+
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.`,
|
|
88
|
+
}, null, 2),
|
|
89
|
+
}],
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: 'text', text: `Error reading file: ${fileMessage}` }],
|
|
42
95
|
isError: true,
|
|
43
96
|
}
|
|
44
97
|
}
|