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 +6 -4
- package/apreton/mcp.md +38 -0
- package/mcp/README.md +51 -2
- package/mcp/bin/ratacode-mcp.js +12 -1
- package/mcp/lib/carpeta.js +152 -0
- package/mcp/lib/http.js +63 -3
- package/mcp/lib/seguridad.js +63 -26
- package/mcp/lib/servidor.js +98 -11
- package/mcp/lib/version.js +32 -0
- package/package.json +2 -2
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.
|
|
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
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
|
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\`.
|
package/mcp/bin/ratacode-mcp.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/mcp/lib/seguridad.js
CHANGED
|
@@ -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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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',
|
package/mcp/lib/servidor.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* servidor — las
|
|
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:
|
|
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
|
|
349
|
-
// TODOS los clientes)
|
|
350
|
-
//
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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",
|