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 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** — Abrí la configuración de Claude Desktop.
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** — Agregá estas líneas:
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** — Reiniciá Claude Desktop. La primera vez descarga el paquete automáticamente.
41
+ **Paso 3** — Reinicia Claude Desktop. La primera vez descarga el paquete automáticamente.
42
42
 
43
- Eso es todo. Podés pedirle a Claude cosas como:
43
+ Eso es todo. Puedes pedirle a Claude cosas como:
44
44
 
45
- > *"Buscá datasets sobre educación en datos.gob.cl"*
46
- > *"Mostrá los datos del dataset de matrícula universitaria"*
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
- > Verificá el campo `datastore_available` antes de usar `get_resource_data`. Los recursos sin datastore deben descargarse desde su `url`.
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 con el datastore CKAN habilitado. Soporta paginación.
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
- > Si el recurso no tiene datastore habilitado, la herramienta devuelve un mensaje descriptivo indicando cómo acceder al archivo directamente.
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`), abrí otra terminal y ejecutá:
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`. Configurá:
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í podés llamar a cualquier herramienta de forma interactiva y ver las respuestas.
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. Creá `src/tools/tu-herramienta.ts`
210
- 2. Importala y registrala en `src/server.ts`
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 acá
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. Los archivos (CSV, XLS, PDF) deben descargarse desde su URL directa.
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, abrí un issue antes de enviar un PR grande.
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
+ }
@@ -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. Only works for resources with datastore enabled (check datastore_available from get_dataset). Supports pagination via limit and offset.',
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('Resource UUID from a dataset\'s resources list (use get_dataset to obtain it)'),
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, limit ?? 50, offset ?? 0);
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: offset ?? 0,
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 (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');
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: 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}`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-chilegob-dataset",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
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
+ }
@@ -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: '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.',
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('Resource UUID from a dataset\'s resources list (use get_dataset to obtain it)'),
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, limit ?? 50, offset ?? 0)
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: offset ?? 0,
32
+ offset: effectiveOffset,
27
33
  fields: result.fields,
28
34
  records: result.records,
29
35
  }, null, 2),
30
36
  }],
31
37
  }
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')
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: 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}`,
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
  }