ratacode 0.2.5 → 0.2.6

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
@@ -168,11 +168,13 @@ motor**; el de por defecto es **MODO-RATA**:
168
168
  2. **Headless** — `ratacode headless "encargo"`: mandas el encargo por terminal, sin
169
169
  pantalla, y la entrega queda en un fichero.
170
170
  3. **MCP** — `ratacode mcp` (o `node mcp/bin/ratacode-mcp.js --home <casa>`): servidor MCP
171
- por stdio para Claude Code, Codex, ChatGPT web, Rowboat u OpenClaw. Siete herramientas:
171
+ por stdio para Claude Code, Codex, ChatGPT web, Rowboat u OpenClaw. Nueve herramientas:
172
172
  `list_providers` → `list_models` → `run_task` → `get_task_status` → `get_task_result` →
173
- `cancel_task` y `ratacode_status`. Por HTTP (para ChatGPT web) hace falta
174
- `ratacode mcp --http` y `mcp.workspaces` declarado; el túnel es
175
- `node mcp/tunel.mjs --home <casa>` (puerto por defecto del MCP: 3778).
173
+ `cancel_task` y `ratacode_status`, más **`list_files`** y **`read_file`** (sólo lectura, para
174
+ el conector de ChatGPT con cuenta propia: con un plan Pro el modo desarrollador sólo deja usar
175
+ herramientas que no cambian nada). Por HTTP (para ChatGPT web) hace falta `ratacode mcp --http`
176
+ y `mcp.workspaces` declarado; el túnel es `node mcp/tunel.mjs --home <casa>` (puerto por
177
+ defecto del MCP: 3778).
176
178
 
177
179
  > Los rótulos propios y las secciones **Conexiones**, **Modelos locales** y **Modos** están en
178
180
  > español, pero Ajustes → Models y los menús del motor siguen en inglés (los pone el motor, y
package/apreton/mcp.md CHANGED
@@ -151,6 +151,44 @@ cierres.
151
151
  por HTTP puedes darle la URL con la clave y no depender de que acepte un servidor stdio (que es lo
152
152
  que sigue sin estar comprobado, ver arriba).
153
153
 
154
+ ## ChatGPT web con TU cuenta (Pro): conector propio, sólo lectura (R26)
155
+
156
+ Con la cuenta de ChatGPT (plan Pro) sí se puede tener el MCP propio: se llama **modo desarrollador**
157
+ y se activa en ChatGPT › **Ajustes › Seguridad e inicio de sesión › Modo desarrollador** (en
158
+ Enterprise/Edu lo concede un administrador; la política por plan está en el
159
+ [artículo de ayuda de OpenAI](https://help.openai.com/en/articles/12584461-developer-mode-apps-and-full-mcp-connectors-in-chatgpt-beta)).
160
+ Después se crea el conector en **ChatGPT › Plugins** (`https://chatgpt.com/plugins`) → **+** →
161
+ nombre y descripción → en **Conexión**, la **URL pública** del MCP.
162
+
163
+ Tres cosas que conviene saber antes de pelearse con la URL:
164
+
165
+ - **ChatGPT no acepta claves propias.** No puede mandar `Authorization: Bearer <tu-clave>` ni una
166
+ cabecera inventada ([docs de autenticación](https://developers.openai.com/plugins/build/auth):
167
+ «ChatGPT does **not** support … custom API keys»). Por eso la clave va **dentro de la URL**, que
168
+ es una URL-capacidad: quien la tenga, entra. El MCP la admite en la ruta (`/mcp/<clave>`, la de
169
+ siempre), en la consulta (`/mcp?clave=<clave>`) o en la cabecera `Bearer`, por si un cliente no
170
+ traga con una de las tres.
171
+ - **`localhost` no le sirve a ChatGPT**: hay que exponerlo. O con el túnel público de siempre
172
+ (`node mcp/tunel.mjs`, abajo), o con el **Secure MCP Tunnel** de OpenAI
173
+ ([guía](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels)), que pide un
174
+ `tunnel_id` de la Platform y `tunnel-client`. **El túnel lo enciende el humano, no RATACODE.**
175
+ - **Con Pro, sólo lectura.** El MCP publica ahora tres herramientas de sólo lectura, marcadas como
176
+ tales (`readOnlyHint: true`, que es la marca que documenta OpenAI): `ratacode_status` (estado,
177
+ versión, carpetas autorizadas, herramientas y sesiones), `list_files` (listar una carpeta
178
+ autorizada) y `read_file` (leer un fichero de dentro). No gastan claves ni tokens, y el cerco de
179
+ la ruta lo comprueba **el servidor**, con el mismo `lib/lectura.js` de las tareas.
180
+
181
+ > **`CHATGPT_PRO_WRITE = NO DISPONIBLE POR PLAN`.** `run_task` y `cancel_task` siguen ahí y siguen
182
+ > funcionando por stdio y por HTTP, pero un conector de ChatGPT Pro no puede usarlas: el plan sólo
183
+ > habilita herramientas que no cambian nada. Para mandar trabajo, usa Codex, Claude Code o el propio
184
+ > panel de RATACODE.
185
+
186
+ Lo que se puede pedirle a ChatGPT, entonces: «consulta RATACODE y dime qué hay en la carpeta
187
+ autorizada», «léeme `notas.md` de ahí». Y lo que NO puede: nada de fuera de `mcp.workspaces` —ni
188
+ por `..`, ni por ruta absoluta, ni por una unión de Windows, ni por el nombre corto 8.3, ni por
189
+ `\\?\`, ni con una variable de entorno—, ni traerse las instrucciones `AGENTS.md` o las habilidades
190
+ de carpetas de arriba (eso se apaga en cada tarea desde R26; mira «Lo que una tarea puede LEER»).
191
+
154
192
  ## Cómo usarlo
155
193
 
156
194
  1. **`list_models`** — llama primero para ver qué modelos hay, con proveedor, id, contexto, capacidades y estado.
package/mcp/README.md CHANGED
@@ -94,6 +94,36 @@ Y **ya no hay nada que aceptar**: cada tarea va encerrada en las carpetas de
94
94
  bandera `--acepto-lectura-total` de antes es un no-op: se acepta para no romper
95
95
  los comandos viejos, y no hace nada (más abajo, «La LECTURA, encerrada»).
96
96
 
97
+ ### ChatGPT web con tu cuenta (R26): conector propio y túnel
98
+
99
+ ChatGPT **no acepta claves propias** ni cabeceras que le inventes
100
+ ([docs de autenticación](https://developers.openai.com/plugins/build/auth): no admite claves de API
101
+ de cliente), así que la clave viaja **dentro de la URL**: es una URL-capacidad. El servidor la
102
+ admite de tres formas —en la ruta `/mcp/<clave>`, en la consulta `/mcp?clave=<clave>` o en
103
+ `Authorization: Bearer <clave>`— por si un cliente no traga con una de ellas; la que se le da a
104
+ Patxi es la de la ruta. Y **el `Origin` de otra web se corta con un 403**: sólo se atiende desde
105
+ loopback y desde OpenAI.
106
+
107
+ Pasos (documentación oficial de hoy: [conectar y probar](https://developers.openai.com/plugins/deploy/connect-chatgpt)):
108
+
109
+ 1. En ChatGPT: **Ajustes › Seguridad e inicio de sesión › Modo desarrollador** (en ChatGPT Pro la
110
+ política del plan puede limitarlo; el artículo de ayuda de OpenAI lo detalla).
111
+ 2. En el PC: `ratacode mcp --http` (con `mcp.workspaces` declarado) y, en otra ventana,
112
+ `node mcp/tunel.mjs --home <casa>`. El túnel **público lo enciende el humano**, no RATACODE.
113
+ 3. En ChatGPT: **ChatGPT › Plugins** (`https://chatgpt.com/plugins`) → **+** → nombre y descripción
114
+ → en **Conexión**, pegar la URL pública completa (la que imprimió `tunel.mjs`).
115
+ 4. Crear y revisar las herramientas que descubre. Si el plan **no** permite las de escritura, el
116
+ conector funciona igual con las de sólo lectura: `ratacode_status`, `list_files` y `read_file`.
117
+
118
+ > `CHATGPT_PRO_WRITE = NO DISPONIBLE POR PLAN`: con Pro, el conector propio (modo desarrollador) es
119
+ > de sólo lectura. `run_task` y `cancel_task` siguen existiendo y funcionando por stdio y para los
120
+ > clientes que sí pueden escribir; en ChatGPT Pro no aparecerán utilizables.
121
+
122
+ Alternativa nativa (en vez de cloudflared): **Secure MCP Tunnel** de OpenAI
123
+ ([guía](https://developers.openai.com/api/docs/guides/secure-mcp-tunnels)), que necesita un
124
+ `tunnel_id` de la Platform y `tunnel-client`; no sustituye a la URL pública si algún día se publica
125
+ el conector.
126
+
97
127
  ## Las herramientas
98
128
 
99
129
  | Herramienta | Para qué |
@@ -104,7 +134,17 @@ los comandos viejos, y no hace nada (más abajo, «La LECTURA, encerrada»).
104
134
  | `get_task_status` | `queued` · `running` · `completed` · `failed` · `cancelled`. |
105
135
  | `get_task_result` | Respuesta, modelo, proveedor, tokens, coste (si está declarado), duración y errores. |
106
136
  | `cancel_task` | Detiene la tarea de inmediato (se mata el proceso que la ejecuta). |
107
- | `ratacode_status` | Estado del propio servidor: casa, motor, tareas vivas, clientes y últimas líneas del cuaderno. |
137
+ | `ratacode_status` | Estado del servidor: si está vivo, versión, herramientas, **carpetas autorizadas**, sesiones (clientes) y topes. Ni la casa ni ninguna clave. |
138
+ | `list_files` | **Sólo lectura (R26).** Lista una carpeta autorizada (entradas con tipo, tamaño y fecha). Fuera de las carpetas autorizadas, se para. |
139
+ | `read_file` | **Sólo lectura (R26).** Devuelve el texto de un fichero de dentro (256 KB como mucho; si es binario, lo dice). Fuera, se para. |
140
+
141
+ Las tres de sólo lectura (`ratacode_status`, `list_files`, `read_file`) van marcadas con
142
+ `readOnlyHint: true`, `destructiveHint: false` y `openWorldHint: false`, que es lo que OpenAI
143
+ documenta para que el cliente sepa que no cambian nada. **No gastan claves ni tokens**, y el cerco
144
+ de la ruta se comprueba EN EL SERVIDOR con `lib/lectura.js` (el mismo de las tareas): `..`, rutas
145
+ absolutas, uniones que apuntan fuera, nombres cortos 8.3, `\\?\`, UNC y variables de entorno caen
146
+ todos del mismo lado. Por eso son las que puede usar un **ChatGPT Pro** en modo desarrollador
147
+ (mira «ChatGPT web» más abajo).
108
148
 
109
149
  `run_task` acepta: `prompt`, `esperar_segundos`, `provider`, `model`,
110
150
  `working_directory`, `context`, `max_tokens`, `timeout`, `allow_dangerous`.
@@ -142,6 +182,14 @@ agente deciden el modelo; esta capa no elige por nadie.
142
182
  trabajos en segundo plano (`tool-jobs`), ni red (`tool-web`), ni subagentes
143
183
  (`tool-subagent*`), ni guiones (`tool-workflow`) ni bucles de agentes (`tool-ralph`).
144
184
  Se apagan una a una en el parche de cada tarea, con su motivo escrito al lado.
185
+ - **Y las dos lecturas que no pasan por ninguna herramienta (R26).** El motor carga solo las
186
+ instrucciones `AGENTS.md` de la carpeta de trabajo **y de todas las de arriba** (y del
187
+ `<casa>\AGENTS.md`) y las habilidades (*skills*) de `<raíz>/.dsh/skills`, `<raíz>/.agents/skills`,
188
+ `<casa>\skills` y `~/.agents/skills`: eso NO lo ve el gancho, porque no es una llamada a
189
+ herramienta. Medido: sin apagarlas, un canario puesto en el `AGENTS.md` de la casa **y** una
190
+ habilidad de fuera **llegaban al modelo**. Se apagan con sus ajustes documentados
191
+ (`agent-instructions` → `maxBytes: 0`; `skill-filesystem` → `includeDefaultRoots: false`), y la
192
+ prueba `pruebas/mcp-lectura.test.mjs` mide el antes y el después con el mismo montaje.
145
193
  - **El sandbox lo impone el core.** Cada tarea arranca en `workspace-write` con su cwd
146
194
  como frontera de escritura, y el MCP le pasa al hijo un parche que **fija** el modo.
147
195
  Ojo con el detalle que costó una ronda: la casa de fábrica trae
@@ -241,7 +289,8 @@ y que `cancel_task` deja la tarea en `cancelled` de verdad.
241
289
  ## Lo que falta (a propósito)
242
290
 
243
291
  - **Del MCP:** nada de transporte. El stdio y el Streamable HTTP están hechos, y el túnel
244
- (`mcp/tunel.mjs`) también; las siete herramientas y sus topes, en marcha.
292
+ (`mcp/tunel.mjs`) también; las nueve herramientas (siete de trabajo y estado, dos de sólo
293
+ lectura) y sus topes, en marcha.
245
294
  - **Del lanzador:** arranque/parada del MCP desde el panel con `MCP: ON/OFF`, la sección
246
295
  **Connections** en Ajustes (puerto, clientes, última actividad) y el botón que explique el MCP
247
296
  dentro de la web. Hoy eso se hace por línea de órdenes y se mira en `<casa>\mcp\`.
@@ -54,6 +54,14 @@ function uso() {
54
54
  'Sin opciones, habla MCP por stdio (lo que espera cualquier cliente MCP local).',
55
55
  'Con --http, habla por los dos a la vez; la clave de la URL se genera y se',
56
56
  'guarda en la casa (en <casa>\\mcp\\http-secret.txt), nunca en el repositorio.',
57
+ 'La clave se puede presentar de tres formas: en la ruta (/mcp/<clave>, la de',
58
+ 'siempre), en la consulta (/mcp?clave=<clave>) o en `Authorization: Bearer',
59
+ '<clave>`. Las dos últimas están ahí por si un cliente no admite la ruta.',
60
+ '',
61
+ 'R26 · TRES HERRAMIENTAS DE SÓLO LECTURA, para ChatGPT (plan Pro sólo puede',
62
+ 'usar las que no cambian nada): ratacode_status, list_files y read_file. Van',
63
+ 'marcadas con readOnlyHint y leen sólo dentro de las carpetas autorizadas: el',
64
+ 'cerco se comprueba en el servidor, con mcp/lib/lectura.js.',
57
65
  '',
58
66
  'CADA TAREA VA ENCERRADA en las carpetas de `mcp.workspaces`: lee y escribe',
59
67
  'sólo ahí, sin terminal, sin red, sin subagentes y sin guiones. Fuera de esas',
@@ -164,7 +172,7 @@ async function main() {
164
172
 
165
173
  aviso('en marcha · casa: ' + casa);
166
174
  aviso('en marcha · motor: ' + dshBin);
167
- aviso('en marcha · herramientas: list_providers, list_models, run_task, get_task_status, get_task_result, cancel_task, ratacode_status');
175
+ aviso('en marcha · herramientas: list_providers, list_models, run_task, get_task_status, get_task_result, cancel_task, ratacode_status, list_files, read_file');
168
176
  aviso('en marcha · tope de tareas: ' + ordenes.tareasPorHora + '/h');
169
177
  tareas.resumir();
170
178
 
@@ -184,6 +192,9 @@ async function main() {
184
192
  clave,
185
193
  rutaClave,
186
194
  alRotar: guardarUrl,
195
+ // Quién llama, para el cuaderno y para `ratacode_status`: en modo sin
196
+ // estado el nombre sólo se puede leer de la petición `initialize`.
197
+ alPresentarse: (nombre) => tareas.verCliente(nombre),
187
198
  host: '127.0.0.1',
188
199
  });
189
200
  // La clave NO se escribe en stderr: los clientes MCP guardan ese stderr en
@@ -0,0 +1,152 @@
1
+ /**
2
+ * carpeta — LAS DOS HERRAMIENTAS DE SOLO LECTURA DEL MCP (R26).
3
+ *
4
+ * Por qué existe este fichero: ChatGPT (plan Pro) sólo puede usar herramientas
5
+ * de SOLO LECTURA por un conector propio en modo desarrollador, y para que un
6
+ * chat pueda «consultar RATACODE y decir qué hay en la carpeta autorizada»
7
+ * hacen falta dos cosas que el motor no ofrece: listar una carpeta y leer un
8
+ * fichero. Las dos, ENCERRADAS y comprobadas AQUÍ, en el servidor.
9
+ *
10
+ * Tres reglas, y ninguna es adorno:
11
+ *
12
+ * 1 · EL CERCO ES EL MISMO QUE EL DE LAS TAREAS. Se usan las funciones puras de
13
+ * `lib/lectura.js` (R25): `canonica()` resuelve rutas relativas, `..`,
14
+ * mayúsculas de Windows, UNC, el prefijo `\\?\` y —importante— sigue los
15
+ * enlaces del trozo que existe con `realpath`, así que un enlace o una
16
+ * unión creada DENTRO que apunta FUERA se juzga por dónde acaba de verdad.
17
+ * Y las raíces se calculan con las MISMAS reglas que `resolverEspacio`
18
+ * (`raicesAutorizadas`), incluido el apretón del modo HTTP.
19
+ *
20
+ * 2 · LO QUE NO ESTÁ DENTRO, NO SE TOCA. Si la ruta se sale, se lanza el mismo
21
+ * error de una línea que ve el agente: «Fuera de la carpeta autorizada:
22
+ * <ruta>». No se dice qué hay fuera, ni si existe.
23
+ *
24
+ * 3 · NADA DE VOLCAR EL DISCO. La lista tiene tope de entradas y la lectura
25
+ * tope de bytes (y no lee binarios). Un chat no necesita más.
26
+ */
27
+ import { closeSync, existsSync, openSync, readdirSync, readSync, statSync } from 'node:fs';
28
+ import { join } from 'node:path';
29
+ import { ajustesMcp } from './casa.js';
30
+ import { canonica, dentroDeAlguna, motivoFuera } from './lectura.js';
31
+ import { raicesAutorizadas } from './seguridad.js';
32
+
33
+ /** Cuántas entradas se listan como mucho de una carpeta. */
34
+ export const TOPE_ENTRADAS = 500;
35
+ /** Cuántos bytes se leen como mucho de un fichero (256 KB: sobra para mirar). */
36
+ export const TOPE_BYTES = 262_144;
37
+
38
+ /**
39
+ * Las carpetas autorizadas de esta casa, con las reglas de las tareas (mismas
40
+ * raíces, mismo apretón en modo HTTP) pero SIN lanzar: `ratacode_status` tiene
41
+ * que poder decir cuáles son aunque la casa esté mal puesta.
42
+ * @param {{casa: string, cwdPorDefecto: string, http?: boolean}} opciones
43
+ * @returns {{raices: string[], raiz: string|null, avisos: string[]}}
44
+ */
45
+ export function raicesDeLaCasa({ casa, cwdPorDefecto, http = false }) {
46
+ const ajustes = ajustesMcp(casa);
47
+ const { raices, avisos } = raicesAutorizadas({ ajustes, cwdPorDefecto, http });
48
+ return { raices, raiz: raices.length > 0 ? raices[0] : null, avisos };
49
+ }
50
+
51
+ /**
52
+ * La ruta canónica pedida, o el error de una línea si se sale del cerco. Una
53
+ * ruta vacía NO es «todo»: es que falta la ruta.
54
+ * @param {{ruta: string, raices: string[], cwd?: string}} opciones
55
+ * @returns {string} la ruta canónica.
56
+ */
57
+ export function resolverDentro({ ruta, raices, cwd }) {
58
+ const pedida = String(ruta ?? '').trim();
59
+ if (pedida === '') throw new Error('falta la ruta (por ejemplo: "." para la carpeta autorizada)');
60
+ const juicio = dentroDeAlguna(pedida, raices ?? [], cwd);
61
+ if (!juicio.dentro) throw new Error(motivoFuera(juicio.canonica === '' ? pedida : juicio.canonica));
62
+ return juicio.canonica;
63
+ }
64
+
65
+ /**
66
+ * Listar una carpeta autorizada, sin salirse.
67
+ * @param {{ruta?: string, raices: string[], cwd?: string, tope?: number}} opciones
68
+ * @returns {{carpeta: string, raices: string[], total: number, devueltas: number, truncado: boolean, entradas: object[]}}
69
+ */
70
+ export function listarCarpeta({ ruta, raices, cwd, tope = TOPE_ENTRADAS }) {
71
+ const lista = Array.isArray(raices) ? raices : [];
72
+ const carpeta = resolverDentro({ ruta, raices: lista, cwd });
73
+ if (!existsSync(carpeta)) throw new Error('no existe: ' + carpeta);
74
+ if (!statSync(carpeta).isDirectory()) throw new Error('no es una carpeta: ' + carpeta);
75
+
76
+ const todas = readdirSync(carpeta, { withFileTypes: true });
77
+ const entradas = [];
78
+ for (const entrada of todas) {
79
+ if (entradas.length >= tope) break;
80
+ entradas.push(describir(join(carpeta, entrada.name), entrada, lista, carpeta));
81
+ }
82
+ return {
83
+ carpeta,
84
+ raices: lista,
85
+ total: todas.length,
86
+ devueltas: entradas.length,
87
+ truncado: todas.length > entradas.length,
88
+ entradas,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Describir UNA entrada. Si es un enlace (o una unión de Windows) se mira a
94
+ * DÓNDE va de verdad: un enlace de dentro que apunta fuera se dice tal cual y
95
+ * no se toca.
96
+ * @param {string} completa - ruta completa de la entrada.
97
+ * @param {import('node:fs').Dirent} entrada - la entrada del directorio.
98
+ * @param {string[]} raices - carpetas autorizadas.
99
+ * @param {string} carpeta - la carpeta que se está listando.
100
+ * @returns {object}
101
+ */
102
+ function describir(completa, entrada, raices, carpeta) {
103
+ const base = { nombre: entrada.name };
104
+ const destino = canonica(completa, carpeta);
105
+ const dentro = dentroDeAlguna(completa, raices, carpeta).dentro;
106
+ if (entrada.isSymbolicLink()) {
107
+ return { ...base, tipo: 'enlace', destino_dentro: dentro, ...(dentro ? {} : { aviso: 'el enlace apunta FUERA de la carpeta autorizada: no se sigue' }) };
108
+ }
109
+ if (!dentro) return { ...base, tipo: 'otro', aviso: 'sin resolver dentro de la carpeta autorizada' };
110
+ if (entrada.isDirectory()) return { ...base, tipo: 'carpeta' };
111
+ if (entrada.isFile()) {
112
+ try {
113
+ const info = statSync(destino);
114
+ return { ...base, tipo: 'fichero', bytes: info.size, modificado: info.mtime.toISOString() };
115
+ } catch {
116
+ return { ...base, tipo: 'fichero' };
117
+ }
118
+ }
119
+ return { ...base, tipo: 'otro' };
120
+ }
121
+
122
+ /**
123
+ * Leer un fichero autorizado, sin salirse y sin volcar un disco.
124
+ * @param {{ruta: string, raices: string[], cwd?: string, maxBytes?: number}} opciones
125
+ * @returns {{fichero: string, bytes: number, bytes_totales: number, truncado: boolean, texto: string}}
126
+ */
127
+ export function leerFichero({ ruta, raices, cwd, maxBytes = TOPE_BYTES }) {
128
+ const lista = Array.isArray(raices) ? raices : [];
129
+ const fichero = resolverDentro({ ruta, raices: lista, cwd });
130
+ if (!existsSync(fichero)) throw new Error('no existe: ' + fichero);
131
+ const info = statSync(fichero);
132
+ if (info.isDirectory()) throw new Error('es una carpeta, no un fichero: ' + fichero + ' (usa list_files)');
133
+
134
+ const tope = Math.min(Math.max(1, Number(maxBytes) || TOPE_BYTES), TOPE_BYTES);
135
+ const trozo = Buffer.alloc(Math.min(tope, Math.max(1, info.size)));
136
+ const descriptor = openSync(fichero, 'r');
137
+ let leidos = 0;
138
+ try {
139
+ leidos = trozo.length === 0 ? 0 : readSync(descriptor, trozo, 0, trozo.length, 0);
140
+ } finally {
141
+ closeSync(descriptor);
142
+ }
143
+ const datos = trozo.subarray(0, leidos);
144
+ if (datos.includes(0)) throw new Error('es un fichero binario (no texto): ' + fichero);
145
+ return {
146
+ fichero,
147
+ bytes: leidos,
148
+ bytes_totales: info.size,
149
+ truncado: info.size > leidos,
150
+ texto: datos.toString('utf8'),
151
+ };
152
+ }
package/mcp/lib/http.js CHANGED
@@ -28,6 +28,17 @@
28
28
  * del fichero de la clave, este módulo la relee cada pocos segundos y adopta la
29
29
  * nueva. Así `tunel.mjs` puede estrenar clave al abrir el túnel y el servidor
30
30
  * que ya está en marcha la acepta.
31
+ *
32
+ * R26 · DOS COSAS MÁS, por ChatGPT:
33
+ * · LA CLAVE SE PUEDE PRESENTAR DE TRES FORMAS: en la ruta (`/mcp/<clave>`,
34
+ * lo de siempre), en la consulta (`/mcp?clave=<clave>`) o en la cabecera
35
+ * `Authorization: Bearer <clave>`. Sin dato de si ChatGPT acepta la clave
36
+ * dentro de la ruta, se dejan preparadas las otras dos: la ruta sigue
37
+ * siendo la forma que se le da a Patxi.
38
+ * · EL `Origin` DE OTRA WEB SE CORTA CON UN 403. Un navegador con una página
39
+ * abierta no tiene por qué hablarle a este servidor. Se permite el origen
40
+ * propio (loopback, cualquier puerto: el inspector de MCP vive en otro) y
41
+ * los de OpenAI (por si su backend mandara `Origin` alguna vez).
31
42
  */
32
43
  import { timingSafeEqual } from 'node:crypto';
33
44
  import { readFileSync } from 'node:fs';
@@ -42,6 +53,35 @@ const SIMULTANEAS_DEFECTO = 8;
42
53
  /** Cada cuánto se mira si la clave del fichero ha cambiado. */
43
54
  const MS_REVISION_CLAVE = 2000;
44
55
 
56
+ /** Los nombres del parámetro de la clave en la consulta, para los clientes que no puedan usar la ruta. */
57
+ const CLAVES_EN_CONSULTA = ['clave', 'key'];
58
+
59
+ /**
60
+ * ¿Este `Origin` es de casa (loopback) o de OpenAI? Cualquier otra web: no.
61
+ * @param {string|undefined} origen - la cabecera `Origin`.
62
+ * @returns {boolean} true si se admite.
63
+ */
64
+ export function origenAdmitido(origen) {
65
+ if (typeof origen !== 'string' || origen.trim() === '') return true; // sin cabecera: un cliente, no un navegador
66
+ let anfitrion;
67
+ try {
68
+ anfitrion = new URL(origen).hostname.toLowerCase();
69
+ } catch {
70
+ return false;
71
+ }
72
+ if (anfitrion === '127.0.0.1' || anfitrion === 'localhost' || anfitrion === '::1' || anfitrion === '[::1]') return true;
73
+ if (anfitrion === 'chatgpt.com' || anfitrion.endsWith('.chatgpt.com')) return true;
74
+ if (anfitrion === 'openai.com' || anfitrion.endsWith('.openai.com')) return true;
75
+ return false;
76
+ }
77
+
78
+ /** El valor de un `Authorization: Bearer <clave>`, si lo hay. */
79
+ function portador(cabecera) {
80
+ if (typeof cabecera !== 'string') return null;
81
+ const trozos = cabecera.trim().split(/\s+/);
82
+ return trozos.length === 2 && trozos[0].toLowerCase() === 'bearer' ? trozos[1] : null;
83
+ }
84
+
45
85
  /** Leer el cuerpo JSON de una petición, con tope. */
46
86
  function leerCuerpo(req) {
47
87
  return new Promise((listo, rechaza) => {
@@ -84,10 +124,10 @@ function claveValida(recibida, buena) {
84
124
 
85
125
  /**
86
126
  * Arrancar el servidor HTTP.
87
- * @param {{fabricaServidor: () => import('@modelcontextprotocol/sdk/server/mcp.js').McpServer, puerto: number, clave: string, rutaClave?: string, alRotar?: (nueva: string) => void, host?: string, simultaneas?: number}} opciones
127
+ * @param {{fabricaServidor: () => import('@modelcontextprotocol/sdk/server/mcp.js').McpServer, puerto: number, clave: string, rutaClave?: string, alRotar?: (nueva: string) => void, host?: string, simultaneas?: number, alPresentarse?: (nombre: string) => void}} opciones
88
128
  * @returns {Promise<{servidor: import('node:http').Server, claveActual: () => string, parar: () => void}>} ya escuchando.
89
129
  */
90
- export function iniciarServidorHttp({ fabricaServidor, puerto, clave, rutaClave, alRotar, host = '127.0.0.1', simultaneas = SIMULTANEAS_DEFECTO }) {
130
+ export function iniciarServidorHttp({ fabricaServidor, puerto, clave, rutaClave, alRotar, host = '127.0.0.1', simultaneas = SIMULTANEAS_DEFECTO, alPresentarse }) {
91
131
  /** La clave viva: puede cambiar si `tunel.mjs` estrena una. */
92
132
  let claveViva = clave;
93
133
  let atendiendose = 0;
@@ -117,9 +157,21 @@ export function iniciarServidorHttp({ fabricaServidor, puerto, clave, rutaClave,
117
157
  });
118
158
 
119
159
  async function manejar(req, res) {
160
+ // El origen, lo PRIMERO: una web ajena no llega ni a probar la clave.
161
+ if (!origenAdmitido(req.headers.origin)) {
162
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
163
+ .end('Origen no permitido: sólo se atiende desde esta máquina (o desde OpenAI).');
164
+ return;
165
+ }
166
+
120
167
  const url = new URL(req.url ?? '/', 'http://' + (req.headers.host ?? 'localhost'));
121
168
  const partes = url.pathname.split('/').filter(Boolean); // ['mcp', '<clave>']
122
- if (partes[0] !== 'mcp' || !claveValida(partes[1], claveViva)) {
169
+ // La clave vale por la ruta (lo de siempre), por la consulta o por la
170
+ // cabecera `Authorization: Bearer`. Ninguna de las tres se registra.
171
+ const candidatas = [partes[1], ...CLAVES_EN_CONSULTA.map((n) => url.searchParams.get(n)), portador(req.headers.authorization)];
172
+ // Una sola pieza de ruta después de `/mcp`: si hay más (`/mcp/<clave>/otra`),
173
+ // es una URL equivocada y se dice, en vez de atenderla como si fuera la buena.
174
+ if (partes[0] !== 'mcp' || partes.length > 2 || !candidatas.some((c) => claveValida(c, claveViva))) {
123
175
  res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('Not found');
124
176
  return;
125
177
  }
@@ -144,6 +196,14 @@ export function iniciarServidorHttp({ fabricaServidor, puerto, clave, rutaClave,
144
196
  }
145
197
  atendiendose += 1;
146
198
  try {
199
+ // Quién llama, del propio `initialize`: sin estado, el servidor que
200
+ // atiende el `notifications/initialized` es OTRO distinto del que vio el
201
+ // `initialize`, así que allí no hay nombre que preguntar y el cuaderno se
202
+ // quedaba en «MCP». Se apunta aquí, de la petición que sí lo trae.
203
+ const nombre = cuerpo?.params?.clientInfo?.name;
204
+ if (cuerpo?.method === 'initialize' && typeof nombre === 'string' && nombre.trim() !== '' && typeof alPresentarse === 'function') {
205
+ alPresentarse(nombre.trim().slice(0, 80));
206
+ }
147
207
  const transporte = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
148
208
  const servidor = fabricaServidor();
149
209
  await servidor.connect(transporte);
@@ -101,6 +101,38 @@ export function esCarpetaDeUsuario(ruta) {
101
101
  return a === comparable(normalizarRuta(homedir())) || a === comparable(normalizarRuta(dirname(homedir())));
102
102
  }
103
103
 
104
+ /**
105
+ * Las raíces autorizadas de una casa, con el apretón del modo HTTP. NO lanza:
106
+ * devuelve la lista (que puede quedar vacía) y los avisos. Se usa desde
107
+ * `resolverEspacio` (las tareas) y desde `lib/carpeta.js` (las dos herramientas
108
+ * de sólo lectura y `ratacode_status`), para que TODOS miren las mismas
109
+ * carpetas por las mismas reglas.
110
+ * @param {{ajustes: object, cwdPorDefecto: string, http?: boolean}} opciones
111
+ * @returns {{raices: string[], avisos: string[]}}
112
+ */
113
+ export function raicesAutorizadas({ ajustes, cwdPorDefecto, http = false }) {
114
+ const avisos = [...ajustes.avisos];
115
+ let raices = ajustes.workspaces.length > 0
116
+ ? ajustes.workspaces.map(normalizarRuta)
117
+ : [normalizarRuta(ajustes.workspacePorDefecto ?? cwdPorDefecto)];
118
+ if (ajustes.workspaces.length === 0) {
119
+ avisos.push('la casa no tiene `mcp.workspaces`: sólo se permite ' + raices[0]);
120
+ }
121
+
122
+ // Ni la raíz de un disco ni la carpeta del usuario como espacio: se niegan en
123
+ // modo HTTP, que es el que se expone. (En local el humano arrancó el servidor
124
+ // en su propia carpeta a propósito, y ahí manda él.)
125
+ if (http) {
126
+ const permitidas = raices.filter((r) => !esRaizDeDisco(r) && !esCarpetaDeUsuario(r));
127
+ for (const fuera of raices.filter((r) => !permitidas.includes(r))) {
128
+ avisos.push('espacio demasiado ancho, lo ignoro: ' + fuera
129
+ + ' (ni la raíz de un disco ni tu carpeta de usuario valen como espacio de trabajo en modo HTTP)');
130
+ }
131
+ raices = permitidas;
132
+ }
133
+ return { raices, avisos };
134
+ }
135
+
104
136
  /**
105
137
  * Resolver el espacio de una tarea, o negarse con un motivo útil.
106
138
  * @param {{casa: string, pedido?: string, cwdPorDefecto: string, http?: boolean}} opciones
@@ -108,7 +140,6 @@ export function esCarpetaDeUsuario(ruta) {
108
140
  */
109
141
  export function resolverEspacio({ casa, pedido, cwdPorDefecto, http = false }) {
110
142
  const ajustes = ajustesMcp(casa);
111
- const avisos = [...ajustes.avisos];
112
143
 
113
144
  // En modo HTTP (el del túnel) el espacio se aprieta: sin `mcp.workspaces`
114
145
  // declarados no se trabaja. Si no, la única raíz sería la carpeta desde la que
@@ -123,30 +154,13 @@ export function resolverEspacio({ casa, pedido, cwdPorDefecto, http = false }) {
123
154
  );
124
155
  }
125
156
 
126
- let raices = ajustes.workspaces.length > 0
127
- ? ajustes.workspaces.map(normalizarRuta)
128
- : [normalizarRuta(ajustes.workspacePorDefecto ?? cwdPorDefecto)];
129
- if (ajustes.workspaces.length === 0) {
130
- avisos.push('la casa no tiene `mcp.workspaces`: sólo se permite ' + raices[0]);
131
- }
132
-
133
- // Ni la raíz de un disco ni la carpeta del usuario como espacio: se niegan en
134
- // modo HTTP, que es el que se expone. (En local el humano arrancó el servidor
135
- // en su propia carpeta a propósito, y ahí manda él.)
136
- if (http) {
137
- const permitidas = raices.filter((r) => !esRaizDeDisco(r) && !esCarpetaDeUsuario(r));
138
- for (const fuera of raices.filter((r) => !permitidas.includes(r))) {
139
- avisos.push('espacio demasiado ancho, lo ignoro: ' + fuera
140
- + ' (ni la raíz de un disco ni tu carpeta de usuario valen como espacio de trabajo en modo HTTP)');
141
- }
142
- if (permitidas.length === 0) {
143
- throw new Error(
144
- 'no queda ningún espacio de trabajo admisible: ni la raíz de un disco ni tu carpeta de usuario ('
145
- + homedir() + ' y ' + dirname(homedir()) + ') valen. Declara `mcp.workspaces` en '
146
- + casa + '\\settings.yaml con carpetas de trabajo de verdad.',
147
- );
148
- }
149
- raices = permitidas;
157
+ const { raices, avisos } = raicesAutorizadas({ ajustes, cwdPorDefecto, http });
158
+ if (http && raices.length === 0) {
159
+ throw new Error(
160
+ 'no queda ningún espacio de trabajo admisible: ni la raíz de un disco ni tu carpeta de usuario ('
161
+ + homedir() + ' y ' + dirname(homedir()) + ') valen. Declara `mcp.workspaces` en '
162
+ + casa + '\\settings.yaml con carpetas de trabajo de verdad.',
163
+ );
150
164
  }
151
165
 
152
166
  const candidato = pedido === undefined || pedido === null || String(pedido).trim() === ''
@@ -217,7 +231,13 @@ function yamlSeguro(texto) {
217
231
  * ({@link HERRAMIENTAS_QUE_SE_APAGAN});
218
232
  * 4. el cerco de la LECTURA: se inserta `./lectura.js` (el plugin que copia
219
233
  * {@link copiarCerco}) con las carpetas autorizadas dentro;
220
- * 5. nada más: no se toca ni un fichero del motor.
234
+ * 5. las dos lecturas que NO pasan por herramienta ninguna (R26): las
235
+ * instrucciones `AGENTS.md` de las carpetas de arriba y las habilidades de
236
+ * `<raíz>/.dsh/skills`, `<raíz>/.agents/skills`, `<casa>\skills` y
237
+ * `~/.agents/skills`. El gancho no las ve porque no son una llamada a
238
+ * herramienta: se apagan con sus ajustes documentados (`maxBytes: 0` y
239
+ * `includeDefaultRoots: false`).
240
+ * 6. nada más: no se toca ni un fichero del motor.
221
241
  *
222
242
  * El `insert` es la única forma de AÑADIR una fila (un parche con `id` sólo
223
243
  * retoca una que ya exista: `cordis-plugin-include/lib/index.js:67-89`), y el
@@ -247,6 +267,23 @@ export function parcheDePolitica({ modo, espacio, raices }) {
247
267
  for (const [id, motivo] of HERRAMIENTAS_QUE_SE_APAGAN) {
248
268
  lineas.push('', '# ' + id + ': ' + motivo, '- id: ' + id, ' disabled: true');
249
269
  }
270
+ lineas.push(
271
+ '',
272
+ '# Las INSTRUCCIONES (AGENTS.md) se leen del cwd Y de TODAS las carpetas de arriba',
273
+ '# (y de <casa>\\AGENTS.md), sin pasar por ninguna herramienta: el gancho no las ve.',
274
+ '# `maxBytes: 0` es la forma documentada de apagar su carga (`dsh-agent-instructions`:',
275
+ '# «non-positive or non-finite disables loading»).',
276
+ '- id: agent-instructions',
277
+ ' config:',
278
+ ' maxBytes: 0',
279
+ '',
280
+ '# Y las HABILIDADES (skills) se descubren en <raíz-del-proyecto>/.dsh/skills,',
281
+ '# <raíz>/.agents/skills, <casa>\\skills y ~/.agents/skills: también fuera del cerco.',
282
+ '# Sin raíces por defecto no se monta ninguna («project and user roots»).',
283
+ '- id: skill-filesystem',
284
+ ' config:',
285
+ ' includeDefaultRoots: false',
286
+ );
250
287
  lineas.push(
251
288
  '',
252
289
  '# El cerco de la LECTURA: un plugin de cordis que engancha `tools/pre-execute` y',
@@ -1,5 +1,5 @@
1
1
  /**
2
- * servidor — las siete herramientas MCP de RATACODE.
2
+ * servidor — las herramientas MCP de RATACODE.
3
3
  *
4
4
  * Esto es una capa FINA: aquí no se decide qué modelo usar (salvo el que la
5
5
  * casa ya tiene por defecto, y se dice cuál), no se habla con ningún proveedor
@@ -10,6 +10,13 @@
10
10
  * nada de acciones peligrosas sin autorización, ni una clave en ninguna
11
11
  * respuesta, y un tope de tareas por hora para no gastar de más cuando el
12
12
  * servidor está expuesto (sobre todo por el túnel de Cloudflare).
13
+ *
14
+ * R26 · Y TRES HERRAMIENTAS DE SOLO LECTURA, porque ChatGPT (plan Pro) sólo
15
+ * puede usar las que no cambian nada: `ratacode_status`, `list_files` y
16
+ * `read_file`. Las tres van marcadas con `readOnlyHint: true` (es la marca que
17
+ * documenta OpenAI para que el cliente sepa que no cambian estado), no gastan
18
+ * tokens ni claves, y leen SÓLO dentro de las carpetas autorizadas, con el
19
+ * cerco de `lib/lectura.js` comprobado aquí, en el servidor.
13
20
  */
14
21
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
22
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
@@ -20,11 +27,37 @@ import { catalogo, credencialDeProveedor, resolverRuta } from './modelos.js';
20
27
  import { faltaLaClave } from './claves.js';
21
28
  import { aviso } from './registro.js';
22
29
  import { resolverEspacio, resolverModo } from './seguridad.js';
30
+ import { listarCarpeta, leerFichero, raicesDeLaCasa, TOPE_ENTRADAS } from './carpeta.js';
23
31
  import { Tareas } from './tareas.js';
32
+ import { VERSION } from './version.js';
24
33
 
25
34
  /** La ventana del tope de tareas. */
26
35
  const VENTANA_MS = 3600_000;
27
36
 
37
+ /**
38
+ * Las marcas de un herramienta de SOLO LECTURA, tal y como las documenta OpenAI
39
+ * (`developers.openai.com/plugins/build/mcp-server`): `readOnlyHint: true` sólo
40
+ * cuando la herramienta NO puede cambiar estado, `destructiveHint: false` y
41
+ * `openWorldHint: false` (lo que se mira es una carpeta acotada de esta
42
+ * máquina, no Internet).
43
+ */
44
+ const SOLO_LECTURA = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
45
+ /** Y las de una que SÍ cambia cosas (lanza trabajo o lo cancela). */
46
+ const ESCRIBE = { readOnlyHint: false, destructiveHint: false, openWorldHint: false };
47
+
48
+ /** Las herramientas que publica este servidor, con si son de sólo lectura. */
49
+ const HERRAMIENTAS = [
50
+ ['list_providers', true],
51
+ ['list_models', true],
52
+ ['run_task', false],
53
+ ['get_task_status', true],
54
+ ['get_task_result', true],
55
+ ['cancel_task', false],
56
+ ['ratacode_status', true],
57
+ ['list_files', true],
58
+ ['read_file', true],
59
+ ];
60
+
28
61
  /** El envoltorio de toda respuesta: JSON legible para cualquier agente. */
29
62
  function comoTexto(dato) {
30
63
  return { content: [{ type: 'text', text: JSON.stringify(dato, null, 2) }] };
@@ -77,6 +110,8 @@ function instrucciones() {
77
110
  return [
78
111
  'RATACODE está disponible como servidor MCP: úsalo para delegar trabajos a los modelos configurados en esta máquina.',
79
112
  '',
113
+ 'SI SÓLO PUEDES LEER (ChatGPT con conector propio en modo desarrollador: las herramientas de escritura no están disponibles en todos los planes), usa estas tres: `ratacode_status` (estado del servidor, versión, carpetas autorizadas y herramientas), `list_files` (lista una carpeta autorizada) y `read_file` (lee un fichero de una carpeta autorizada). Las tres están marcadas como de sólo lectura y no gastan tokens ni claves.',
114
+ '',
80
115
  'Antes de ejecutar una tarea:',
81
116
  '1. consulta list_models',
82
117
  '2. lanza run_task con el modelo que te hayan pedido',
@@ -125,6 +160,7 @@ export function registrarHerramientas(servidor, ctx) {
125
160
  title: 'Proveedores de RATACODE',
126
161
  description: 'Los proveedores configurados en RATACODE, con si tienen la credencial puesta en la casa (RATACODE › Ajustes › Models). Nunca devuelve ninguna clave.',
127
162
  inputSchema: {},
163
+ annotations: SOLO_LECTURA,
128
164
  },
129
165
  conRed(async () => {
130
166
  const { proveedores, porDefecto, avisos } = await catalogo(casa);
@@ -156,6 +192,7 @@ export function registrarHerramientas(servidor, ctx) {
156
192
  inputSchema: {
157
193
  provider: z.string().optional().describe('Filtra por proveedor (por ejemplo "b-ai").'),
158
194
  },
195
+ annotations: SOLO_LECTURA,
159
196
  },
160
197
  conRed(async ({ provider }) => {
161
198
  const { modelos, porDefecto, avisos } = await catalogo(casa);
@@ -189,6 +226,7 @@ export function registrarHerramientas(servidor, ctx) {
189
226
  timeout: z.number().int().positive().optional().describe('Tiempo máximo en milisegundos antes de cancelar la tarea. Por defecto ' + ajustes.timeoutPorDefectoMs + ' ms (' + Math.round(ajustes.timeoutPorDefectoMs / 60000) + ' min); máximo ' + ajustes.timeoutMaximoMs + ' ms.'),
190
227
  allow_dangerous: z.boolean().optional().describe('Pedir acceso total al disco. Requiere que el humano lo haya permitido en la casa; si no, se deniega.'),
191
228
  },
229
+ annotations: ESCRIBE,
192
230
  },
193
231
  conRed(async (args) => {
194
232
  // Tope del encargo: un prompt enorme es un gasto enorme y una espera peor.
@@ -308,6 +346,7 @@ export function registrarHerramientas(servidor, ctx) {
308
346
  title: 'Estado de una tarea',
309
347
  description: 'Estado de una tarea: queued, running, completed, failed o cancelled.',
310
348
  inputSchema: { task_id: z.string().min(1).describe('El task_id que devolvió run_task.') },
349
+ annotations: SOLO_LECTURA,
311
350
  },
312
351
  conRed(async ({ task_id }) => comoTexto(tareas.estado(task_id))),
313
352
  );
@@ -319,6 +358,7 @@ export function registrarHerramientas(servidor, ctx) {
319
358
  title: 'Resultado de una tarea',
320
359
  description: 'La respuesta de una tarea terminada, con modelo, proveedor, tokens, coste (si está declarado), duración y errores.',
321
360
  inputSchema: { task_id: z.string().min(1).describe('El task_id que devolvió run_task.') },
361
+ annotations: SOLO_LECTURA,
322
362
  },
323
363
  conRed(async ({ task_id }) => comoTexto(tareas.resultado(task_id))),
324
364
  );
@@ -330,6 +370,7 @@ export function registrarHerramientas(servidor, ctx) {
330
370
  title: 'Cancelar una tarea',
331
371
  description: 'Detiene una tarea en marcha de inmediato (se mata el proceso que la ejecuta).',
332
372
  inputSchema: { task_id: z.string().min(1).describe('El task_id que devolvió run_task.') },
373
+ annotations: ESCRIBE,
333
374
  },
334
375
  conRed(async ({ task_id }) => comoTexto(await tareas.cancelar(task_id))),
335
376
  );
@@ -339,19 +380,25 @@ export function registrarHerramientas(servidor, ctx) {
339
380
  'ratacode_status',
340
381
  {
341
382
  title: 'Estado de RATACODE MCP',
342
- description: 'Estado del propio servidor: tareas vivas, tope por hora y espacios autorizados. No devuelve la casa ni la actividad de otros clientes.',
383
+ description: 'Estado del propio servidor: si está vivo, su versión, las herramientas que publica, las carpetas autorizadas, las sesiones (clientes) que han hablado con él y los topes. No devuelve la casa, ni las claves, ni la actividad de otros clientes. Úsalo al empezar, para saber con qué cuentas.',
343
384
  inputSchema: {},
385
+ annotations: SOLO_LECTURA,
344
386
  },
345
387
  conRed(async () => {
346
388
  const resumen = tareas.resumir();
389
+ const { raices, raiz, avisos } = raicesDeLaCasa({ casa, cwdPorDefecto, http: ctx.http === true });
347
390
  // Ni la casa (el dato que sirve en bandeja para ir a leer
348
- // `.credentials.yaml`), ni el cuaderno de actividad (lleva los encargos de
349
- // TODOS los clientes), ni las últimas tareas de otros: cada cliente ve lo
350
- // suyo y el estado del servidor.
391
+ // `.credentials.yaml`), ni el motor, ni el cuaderno de actividad (lleva los
392
+ // encargos de TODOS los clientes): cada cliente ve lo suyo, lo que puede
393
+ // tocar y cómo está el servidor.
394
+ const { casa: _casa, motor: _motor, ...servidor } = resumen.mcp;
351
395
  return comoTexto({
352
- mcp: resumen.mcp,
353
- actualizado: resumen.actualizado,
354
- espacios_autorizados: ajustes.workspaces.length > 0 ? ajustes.workspaces : ['(sin lista; sólo el espacio por defecto)'],
396
+ vivo: true,
397
+ version: VERSION,
398
+ servidor: { ...servidor, transporte: ctx.http === true ? ['stdio', 'http'] : ['stdio'] },
399
+ herramientas: HERRAMIENTAS.map(([nombre, soloLectura]) => ({ nombre, solo_lectura: soloLectura })),
400
+ carpeta_autorizada: { raiz, raices },
401
+ sesiones: { clientes: resumen.clientes, tareas_totales: resumen.tareas_totales, tareas_activas: resumen.tareas_activas },
355
402
  permitir_peligroso: ajustes.permitirPeligroso,
356
403
  topes: {
357
404
  tareas_por_hora: ctx.tareasPorHora,
@@ -360,15 +407,55 @@ export function registrarHerramientas(servidor, ctx) {
360
407
  timeout_por_defecto_ms: ajustes.timeoutPorDefectoMs,
361
408
  timeout_maximo_ms: ajustes.timeoutMaximoMs,
362
409
  prompt_max_caracteres: ajustes.promptMaxCaracteres,
410
+ entradas_por_listado: TOPE_ENTRADAS,
363
411
  },
364
- nota: 'Para ver el cuaderno de actividad y las tareas de todos los clientes, míralo en la casa (o en el panel), no por MCP.',
412
+ avisos,
413
+ nota: 'Esta herramienta es de SÓLO LECTURA, como list_files y read_file. Para ver el cuaderno de actividad y las tareas de todos los clientes, míralo en la casa (o en el panel), no por MCP.',
365
414
  });
366
415
  }),
367
416
  );
417
+
418
+ // ── list_files (R26 · sólo lectura, para ChatGPT) ─────────────────────────
419
+ servidor.registerTool(
420
+ 'list_files',
421
+ {
422
+ title: 'Listar una carpeta autorizada',
423
+ description: 'Lista lo que hay en una carpeta de las autorizadas (por defecto, la primera). Sólo lectura: no cambia nada. Si la ruta se sale de las carpetas autorizadas, la herramienta se para y lo dice. Devuelve nombre, tipo, tamaño y fecha de cada entrada.',
424
+ inputSchema: {
425
+ ruta: z.string().optional().describe('Carpeta a listar: relativa a la carpeta autorizada, o absoluta pero DENTRO de ella. Por defecto, la carpeta autorizada.'),
426
+ },
427
+ annotations: SOLO_LECTURA,
428
+ },
429
+ conRed(async ({ ruta }) => {
430
+ const { raices, raiz, avisos } = raicesDeLaCasa({ casa, cwdPorDefecto, http: ctx.http === true });
431
+ if (raices.length === 0) throw new Error('esta casa no tiene ninguna carpeta autorizada: mira los avisos en ratacode_status');
432
+ const listado = listarCarpeta({ ruta: ruta ?? raiz, raices, cwd: raiz });
433
+ return comoTexto({ ...listado, avisos });
434
+ }),
435
+ );
436
+
437
+ // ── read_file (R26 · sólo lectura, para ChatGPT) ──────────────────────────
438
+ servidor.registerTool(
439
+ 'read_file',
440
+ {
441
+ title: 'Leer un fichero autorizado',
442
+ description: 'Devuelve el texto de un fichero que esté DENTRO de las carpetas autorizadas. Sólo lectura: no cambia nada. Si la ruta se sale, la herramienta se para y lo dice; si es binario o muy grande, también (y en ese caso devuelve el principio y avisa).',
443
+ inputSchema: {
444
+ ruta: z.string().min(1).describe('Fichero a leer: relativo a la carpeta autorizada, o absoluto pero DENTRO de ella.'),
445
+ },
446
+ annotations: SOLO_LECTURA,
447
+ },
448
+ conRed(async ({ ruta }) => {
449
+ const { raices, raiz, avisos } = raicesDeLaCasa({ casa, cwdPorDefecto, http: ctx.http === true });
450
+ if (raices.length === 0) throw new Error('esta casa no tiene ninguna carpeta autorizada: mira los avisos en ratacode_status');
451
+ const leido = leerFichero({ ruta, raices, cwd: raiz });
452
+ return comoTexto({ ...leido, raices, avisos });
453
+ }),
454
+ );
368
455
  }
369
456
 
370
457
  /**
371
- * Montar el servidor MCP con sus siete herramientas.
458
+ * Montar el servidor MCP con sus herramientas.
372
459
  * @param {{casa: string, dshBin: string, cwdPorDefecto: string, tareasPorHora?: number, http?: boolean}} opciones - la casa, el motor, el cwd, el tope y si se habla por HTTP.
373
460
  * @returns {{servidor: McpServer, tareas: Tareas, fabricaServidor: () => McpServer}}
374
461
  */
@@ -385,7 +472,7 @@ export function montarServidor({ casa, dshBin, cwdPorDefecto, tareasPorHora = 30
385
472
  * un mismo servidor a varios transportes a la vez) sin perder el estado. */
386
473
  function fabricaServidor() {
387
474
  const servidor = new McpServer(
388
- { name: 'ratacode', version: '0.1.0' },
475
+ { name: 'ratacode', version: VERSION },
389
476
  { instructions: instrucciones() },
390
477
  );
391
478
  registrarHerramientas(servidor, { casa, dshBin, cwdPorDefecto, tareas, marcasTarea, tareasPorHora, http });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * version — la versión de RATACODE, leída del manifiesto (R26).
3
+ *
4
+ * Por qué un fichero para esto: el cliente MCP (y ChatGPT, al enseñar el
5
+ * conector) ven el nombre y la VERSIÓN del servidor en el handshake, y
6
+ * `ratacode_status` la publica. Estaba escrita a mano («0.1.0») y se quedó
7
+ * vieja; leerla del `package.json` que de verdad se instaló es lo único que no
8
+ * miente.
9
+ *
10
+ * Dos sitios, en orden: el paquete del producto (`../../package.json`, que es
11
+ * como viaja dentro de `ratacode`) y el del MCP suelto (`../package.json`).
12
+ */
13
+ import { readFileSync } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ /** La carpeta de `mcp/lib`. */
18
+ const AQUI = dirname(fileURLToPath(import.meta.url));
19
+
20
+ /** La versión, o `0.0.0` si no hay manifiesto legible (no se inventa). */
21
+ export const VERSION = leer();
22
+
23
+ /** Leer la versión del primer manifiesto que se deje. */
24
+ function leer() {
25
+ for (const relativa of ['../../package.json', '../package.json']) {
26
+ try {
27
+ const pkg = JSON.parse(readFileSync(join(AQUI, relativa), 'utf8'));
28
+ if (typeof pkg.version === 'string' && pkg.version.trim() !== '') return pkg.version.trim();
29
+ } catch { /* se prueba el siguiente */ }
30
+ }
31
+ return '0.0.0';
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ratacode",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "RATACODE · la terminal de trabajo con IA: le mandas el trabajo pesado a modelos baratos (o a los tuyos, en local) y el resultado vuelve a tu agente o a tu chat.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "scripts": {
22
22
  "postinstall": "node bin/instalacion.js",
23
- "test": "node pruebas/piel.test.mjs && node pruebas/modos.test.mjs && node pruebas/parche.test.mjs && node pruebas/mcp-espacios.test.mjs && node pruebas/mcp-cerrado.test.mjs"
23
+ "test": "node pruebas/piel.test.mjs && node pruebas/modos.test.mjs && node pruebas/parche.test.mjs && node pruebas/mcp-espacios.test.mjs && node pruebas/mcp-cerrado.test.mjs && node pruebas/mcp-lectura.test.mjs"
24
24
  },
25
25
  "files": [
26
26
  "bin",