mcp-phase-gate 2.0.0 → 2.0.1

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/build/index.js CHANGED
@@ -11,9 +11,10 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
11
11
  import * as fs from "fs";
12
12
  import * as path from "path";
13
13
  import { DOCS, REPOS, SERVER_NAME, SERVER_VERSION, requireBasePath, stateFile } from "./config.js";
14
- import { describeProjects, discoverProjects, estaDentro, findProjectFolder, requireProjectFolder, resolveDocPath, taskFolderPath, } from "./paths.js";
14
+ import { describeProjects, discoverProjects, estaDentroReal, findProjectFolder, leerTextoAcotado, requireProjectFolder, resolveDocPath, resolveRepoPath, taskFolderPath, validarSegmento, } from "./paths.js";
15
15
  import { describePhases, discoverPhases, loadPhasePrompt, nombreContextoInicial, } from "./phases.js";
16
16
  import { avisoDeCompuerta, describeEstado, guardarPuntero, limpiarEstadoViejo, listarTareas, tareaActiva, } from "./tasks.js";
17
+ import { validarLlamada } from "./schemas.js";
17
18
  const server = new Server({
18
19
  name: SERVER_NAME,
19
20
  version: SERVER_VERSION,
@@ -139,25 +140,34 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
139
140
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
140
141
  const { name, arguments: args } = request.params;
141
142
  try {
142
- if (name === "start_task") {
143
- const proj = String(args?.project).trim();
144
- const taskName = String(args?.task_name);
145
- const context = String(args?.initial_context);
143
+ // Todo argumento se valida aquí, antes de que ninguna tool lo use. Un campo ausente o de
144
+ // otro tipo devuelve un error que lo nombra, en vez de convertirse en "undefined".
145
+ const llamada = validarLlamada(name, args);
146
+ if (llamada.tool === "start_task") {
147
+ const proj = llamada.args.project; // el schema ya le quitó los espacios
148
+ const taskName = llamada.args.task_name;
149
+ const context = llamada.args.initial_context;
146
150
  // Estrenar proyecto tiene que ser deliberado: sin esta puerta, un nombre mal escrito
147
151
  // creaba una carpeta suelta en la Documentación y nadie se enteraba hasta buscarla.
148
152
  let carpetaProyecto = findProjectFolder(proj);
149
153
  let estrenado = false;
150
154
  if (!carpetaProyecto) {
151
- if (args?.crear_proyecto !== true) {
155
+ if (llamada.args.crear_proyecto !== true) {
152
156
  throw new Error(`No existe el proyecto "${proj}". Los proyectos disponibles son: ${describeProjects()}. ` +
153
157
  `Si el usuario te confirmó que quiere estrenar el proyecto "${proj.toUpperCase()}", ` +
154
158
  `vuelve a llamar a start_task con crear_proyecto: true.`);
155
159
  }
160
+ // El nombre se vuelve carpeta tal cual: "../evil" la creaba fuera de la Documentación.
161
+ validarSegmento(proj, "project");
156
162
  carpetaProyecto = proj.toUpperCase();
157
163
  estrenado = true;
158
164
  }
159
165
  const carpetaTarea = taskFolderPath(carpetaProyecto, taskName);
160
166
  const contextoInicial = path.join(carpetaTarea, nombreContextoInicial());
167
+ const docsBase = requireBasePath(DOCS);
168
+ if (!estaDentroReal(docsBase, carpetaTarea)) {
169
+ throw new Error(`Acceso denegado: la carpeta de la tarea queda fuera de ${docsBase}.`);
170
+ }
161
171
  // start_task escribe el contexto inicial sin preguntar, así que llamarlo sobre una tarea
162
172
  // que ya existe borraba el requerimiento original — el único documento que no se puede
163
173
  // regenerar leyendo el código. Retomar una tarea es switch_task, que no toca archivos.
@@ -178,19 +188,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
178
188
  content: [{ type: "text", text: `Tarea '${taskName}' inicializada correctamente. Archivo guardado en: ${contextoInicial}\nTarea activa de ${carpetaProyecto}: ${taskName}.${aviso}` }],
179
189
  };
180
190
  }
181
- if (name === "get_phase_prompt") {
182
- const phase = Number(args?.phase);
183
- if (!Number.isInteger(phase)) {
184
- throw new Error(`Debes indicar un número de fase. Disponibles: ${PHASES_DESCRIPTION}.`);
185
- }
191
+ if (llamada.tool === "get_phase_prompt") {
192
+ // Que sea un entero ya lo garantiza el schema; si la fase no existe, lo dice loadPhasePrompt.
193
+ const { phase, project, task_name } = llamada.args;
186
194
  const prompt = loadPhasePrompt(phase);
187
195
  // La compuerta avisa, no bloquea: el prompt se entrega siempre. Si comprobarla fallara
188
196
  // por lo que sea, entregarlo igual es mejor que dejar al usuario sin fase por un aviso.
189
197
  let aviso = null;
190
198
  try {
191
199
  aviso = avisoDeCompuerta(phase, discoverPhases(), {
192
- ...(args?.project ? { project: String(args.project) } : {}),
193
- ...(args?.task_name ? { taskName: String(args.task_name) } : {}),
200
+ ...(project ? { project } : {}),
201
+ ...(task_name ? { taskName: task_name } : {}),
194
202
  });
195
203
  }
196
204
  catch (error) {
@@ -200,9 +208,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
200
208
  content: [{ type: "text", text: aviso ? `${aviso}\n\n${prompt}` : prompt }],
201
209
  };
202
210
  }
203
- if (name === "get_active_task") {
211
+ if (llamada.tool === "get_active_task") {
204
212
  const phases = discoverPhases();
205
- const pedido = args?.project ? String(args.project) : "";
213
+ const pedido = llamada.args.project ?? "";
206
214
  // Con proyecto: la suya. Sin proyecto: la de cada uno. Antes había un único puntero
207
215
  // global y responder "la activa" a secas obligaba a adivinar cuál de dos ventanas
208
216
  // preguntaba; listar todas es la respuesta honesta cuando no se sabe.
@@ -230,9 +238,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
230
238
  content: [{ type: "text", text: `${bloques.join("\n\n")}\n\nPunteros guardados en: ${stateFile()}` }],
231
239
  };
232
240
  }
233
- if (name === "list_tasks") {
241
+ if (llamada.tool === "list_tasks") {
234
242
  const phases = discoverPhases();
235
- const pedido = args?.project ? String(args.project) : "";
243
+ const pedido = llamada.args.project ?? "";
236
244
  const proyectos = pedido
237
245
  ? [requireProjectFolder(pedido)]
238
246
  : [...discoverProjects().values()].sort();
@@ -254,10 +262,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
254
262
  content: [{ type: "text", text: bloques.join("\n\n") || "No hay proyectos todavía." }],
255
263
  };
256
264
  }
257
- if (name === "switch_task") {
265
+ if (llamada.tool === "switch_task") {
258
266
  const phases = discoverPhases();
259
- const carpetaProyecto = requireProjectFolder(String(args?.project));
260
- const taskName = String(args?.task_name);
267
+ const carpetaProyecto = requireProjectFolder(llamada.args.project);
268
+ const taskName = llamada.args.task_name;
261
269
  const tareas = listarTareas(carpetaProyecto, phases);
262
270
  const encontrada = tareas.find((tarea) => tarea.taskName === taskName);
263
271
  if (!encontrada) {
@@ -272,22 +280,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
272
280
  content: [{ type: "text", text: `Tarea activa de ${carpetaProyecto}: ${taskName}.\nFase: ${describeEstado(encontrada, phases)}\nCarpeta: ${encontrada.folderPath}` }],
273
281
  };
274
282
  }
275
- if (name === "read_central_doc") {
283
+ if (llamada.tool === "read_central_doc") {
276
284
  const docsBase = requireBasePath(DOCS);
277
- const fullPath = resolveDocPath(args);
278
- if (!estaDentro(docsBase, fullPath)) {
285
+ const fullPath = resolveDocPath(llamada.args);
286
+ if (!estaDentroReal(docsBase, fullPath)) {
279
287
  throw new Error(`Acceso denegado: la ruta queda fuera de ${docsBase}.`);
280
288
  }
281
- const content = fs.readFileSync(fullPath, "utf-8");
289
+ const content = leerTextoAcotado(fullPath);
282
290
  return {
283
291
  content: [{ type: "text", text: content }],
284
292
  };
285
293
  }
286
- if (name === "write_central_doc") {
287
- const content = String(args?.content);
294
+ if (llamada.tool === "write_central_doc") {
295
+ const content = llamada.args.content;
288
296
  const docsBase = requireBasePath(DOCS);
289
- const fullPath = resolveDocPath(args);
290
- if (!estaDentro(docsBase, fullPath)) {
297
+ const fullPath = resolveDocPath(llamada.args);
298
+ if (!estaDentroReal(docsBase, fullPath)) {
291
299
  throw new Error(`Acceso denegado: la ruta queda fuera de ${docsBase}.`);
292
300
  }
293
301
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
@@ -296,15 +304,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
296
304
  content: [{ type: "text", text: `Archivo escrito exitosamente en ${fullPath}` }],
297
305
  };
298
306
  }
299
- if (name === "read_cross_repo") {
300
- const repoName = String(args?.repo_name);
301
- const filePath = String(args?.file_path);
302
- const reposBase = requireBasePath(REPOS);
303
- const fullPath = path.join(reposBase, repoName, filePath);
304
- if (!estaDentro(reposBase, fullPath)) {
305
- throw new Error(`Acceso denegado: la ruta queda fuera de ${reposBase}.`);
306
- }
307
- const content = fs.readFileSync(fullPath, "utf-8");
307
+ if (llamada.tool === "read_cross_repo") {
308
+ const fullPath = resolveRepoPath(llamada.args.repo_name, llamada.args.file_path);
309
+ const content = leerTextoAcotado(fullPath);
308
310
  return {
309
311
  content: [{ type: "text", text: content }],
310
312
  };
@@ -335,36 +337,36 @@ function reportarRutas() {
335
337
  // una terminal —que es exactamente lo que hace quien acaba de leer `npx mcp-phase-gate` en el
336
338
  // README— se queda esperando entrada, sin señal de vida, y parece roto. Estas dos banderas
337
339
  // existen para que ese momento devuelva una explicación en vez de un cursor parpadeando.
338
- const AYUDA = `${SERVER_NAME} v${SERVER_VERSION}
339
-
340
- Servidor MCP que parte cada requerimiento en cinco fases con compuerta y obliga a que cada
341
- una deje su documento en una documentación central.
342
-
343
- Esto NO se ejecuta a mano: lo lanza tu cliente MCP. Ejecutado directamente se queda esperando
344
- mensajes JSON-RPC por stdin, que es lo que parece que se colgó.
345
-
346
- Regístralo en tu cliente (Cursor, VS Code, Claude Code) así:
347
-
348
- {
349
- "mcpServers": {
350
- "${SERVER_NAME}": {
351
- "command": "npx",
352
- "args": ["-y", "mcp-phase-gate"],
353
- "env": {
354
- "ORQUESTADOR_DOCS_PATH": "<tu carpeta de documentación>",
355
- "ORQUESTADOR_REPOS_PATH": "<tu carpeta de repositorios>"
356
- }
357
- }
358
- }
359
- }
360
-
361
- Variables de entorno:
362
- ORQUESTADOR_DOCS_PATH Obligatoria. Raíz de la Documentación Central.
363
- ORQUESTADOR_REPOS_PATH Obligatoria. Carpeta con tus repositorios de código.
364
- ORQUESTADOR_TASKS_SUBDIR Subcarpeta de tareas dentro de cada proyecto. Por defecto "Proyectos".
365
- ORQUESTADOR_PROMPTS_PATH Carpeta de prompts propios, que se superponen a los del paquete.
366
- ORQUESTADOR_STATE_PATH Dónde guardar los punteros de tarea activa.
367
-
340
+ const AYUDA = `${SERVER_NAME} v${SERVER_VERSION}
341
+
342
+ Servidor MCP que parte cada requerimiento en cinco fases con compuerta y obliga a que cada
343
+ una deje su documento en una documentación central.
344
+
345
+ Esto NO se ejecuta a mano: lo lanza tu cliente MCP. Ejecutado directamente se queda esperando
346
+ mensajes JSON-RPC por stdin, que es lo que parece que se colgó.
347
+
348
+ Regístralo en tu cliente (Cursor, VS Code, Claude Code) así:
349
+
350
+ {
351
+ "mcpServers": {
352
+ "${SERVER_NAME}": {
353
+ "command": "npx",
354
+ "args": ["-y", "mcp-phase-gate"],
355
+ "env": {
356
+ "ORQUESTADOR_DOCS_PATH": "<tu carpeta de documentación>",
357
+ "ORQUESTADOR_REPOS_PATH": "<tu carpeta de repositorios>"
358
+ }
359
+ }
360
+ }
361
+ }
362
+
363
+ Variables de entorno:
364
+ ORQUESTADOR_DOCS_PATH Obligatoria. Raíz de la Documentación Central.
365
+ ORQUESTADOR_REPOS_PATH Obligatoria. Carpeta con tus repositorios de código.
366
+ ORQUESTADOR_TASKS_SUBDIR Subcarpeta de tareas dentro de cada proyecto. Por defecto "Proyectos".
367
+ ORQUESTADOR_PROMPTS_PATH Carpeta de prompts propios, que se superponen a los del paquete.
368
+ ORQUESTADOR_STATE_PATH Dónde guardar los punteros de tarea activa.
369
+
368
370
  Documentación: https://github.com/Montse2308/mcp-phase-gate`;
369
371
  async function main() {
370
372
  const bandera = process.argv[2];
package/build/paths.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export declare function estaDentro(base: string, ruta: string): boolean;
2
+ export declare function rutaReal(ruta: string): string;
3
+ export declare function estaDentroReal(base: string, ruta: string): boolean;
4
+ export declare function validarSegmento(valor: string, campo: string): string;
2
5
  export declare function discoverProjects(): Map<string, string>;
3
6
  export declare function describeProjects(): string;
4
7
  export declare function findProjectFolder(project: string): string | null;
@@ -6,5 +9,14 @@ export declare function requireProjectFolder(project: string): string;
6
9
  export declare function taskFolderPath(carpetaProyecto: string, taskName: string): string;
7
10
  export declare function resolveTaskFolder(project: string, taskName: string): string;
8
11
  export declare function tasksRootPath(carpetaProyecto: string): string;
9
- export declare function resolveDocPath(args: any): string;
12
+ export type ArgumentosDeDocumento = {
13
+ project?: string | undefined;
14
+ task_name?: string | undefined;
15
+ file_name?: string | undefined;
16
+ file_path?: string | undefined;
17
+ };
18
+ export declare function resolveDocPath(args: ArgumentosDeDocumento): string;
19
+ export declare function resolveRepoPath(repoName: string, filePath: string): string;
20
+ export declare const LIMITE_LECTURA_BYTES = 1048576;
21
+ export declare function leerTextoAcotado(ruta: string): string;
10
22
  //# sourceMappingURL=paths.d.ts.map
package/build/paths.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // busca. Por eso además de armarlas hay que comprobar que caigan dentro de la base.
7
7
  import * as fs from "fs";
8
8
  import * as path from "path";
9
- import { DOCS, requireBasePath, tasksSubdir } from "./config.js";
9
+ import { DOCS, REPOS, requireBasePath, tasksSubdir } from "./config.js";
10
10
  // Comprueba que una ruta quede dentro de otra. No sirve startsWith, y falla en las dos
11
11
  // direcciones: con base "C:\PROYECTOS\TRABAJO" deja pasar "C:\PROYECTOS\TRABAJO_VIEJO",
12
12
  // que empieza igual sin estar dentro; y rechaza rutas válidas cuando la base viene escrita
@@ -15,6 +15,60 @@ export function estaDentro(base, ruta) {
15
15
  const relativa = path.relative(path.resolve(base), path.resolve(ruta));
16
16
  return relativa === "" || (!relativa.startsWith("..") && !path.isAbsolute(relativa));
17
17
  }
18
+ // Resuelve symlinks y junctions del ancestro existente más cercano y vuelve a unir el resto.
19
+ // Hace falta porque la ruta que se va a escribir casi nunca existe todavía, y realpath solo
20
+ // funciona sobre lo que existe. "Existe" se mira con lstat y no con existsSync: un enlace roto
21
+ // cuenta como existente, realpath falla sobre él y la operación se rechaza. Con existsSync se
22
+ // saltaría, y escribir a través de él crearía su destino, esté donde esté.
23
+ export function rutaReal(ruta) {
24
+ let existente = path.resolve(ruta);
25
+ const pendientes = [];
26
+ while (!fs.lstatSync(existente, { throwIfNoEntry: false })) {
27
+ const padre = path.dirname(existente);
28
+ if (padre === existente)
29
+ break; // ni la raíz existe: que realpath dé el error
30
+ pendientes.unshift(path.basename(existente));
31
+ existente = padre;
32
+ }
33
+ return path.join(fs.realpathSync.native(existente), ...pendientes);
34
+ }
35
+ // estaDentro sobre rutas reales. Comparar el texto no basta: un symlink dentro de la
36
+ // Documentación que apunta fuera tiene una ruta que "está dentro" y un destino que no.
37
+ // La base también se resuelve: una DOCS que es symlink sigue valiendo.
38
+ export function estaDentroReal(base, ruta) {
39
+ return estaDentro(rutaReal(base), rutaReal(ruta));
40
+ }
41
+ // Un nombre de proyecto, tarea o repo es UN segmento de ruta, portable entre sistemas:
42
+ // la Documentación viaja entre equipos con Windows, macOS y Linux. Lo que se rechaza es lo
43
+ // que hoy ya era peligroso (separadores, "..") o lo que Windows no puede guardar y rompe la
44
+ // sincronización (caracteres reservados, punto o espacio final, CON, NUL…).
45
+ const CARACTERES_RESERVADOS = /[<>:"|?*]/;
46
+ const CARACTERES_DE_CONTROL = /[\u0000-\u001f\u007f]/;
47
+ // Windows también reserva COM y LPT con los superíndices ¹²³, y las consolas CONIN$ y CONOUT$.
48
+ // Los espacios antes de la extensión no cuentan: "nul .txt" sigue siendo NUL.
49
+ const NOMBRES_RESERVADOS_WINDOWS = /^(con|prn|aux|nul|conin\$|conout\$|com[1-9¹²³]|lpt[1-9¹²³]) *(\..*)?$/i;
50
+ export function validarSegmento(valor, campo) {
51
+ const incumple = (regla) => {
52
+ throw new Error(`${campo} ${JSON.stringify(valor)} no es válido: ${regla}. Tiene que ser un solo nombre de ` +
53
+ `carpeta o archivo, que funcione igual en Windows, macOS y Linux.`);
54
+ };
55
+ if (valor.trim() === "")
56
+ incumple("está vacío");
57
+ if (valor === "." || valor === "..")
58
+ incumple(`no puede ser "${valor}"`);
59
+ if (/[\\/]/.test(valor))
60
+ incumple("no puede llevar / ni \\");
61
+ if (CARACTERES_DE_CONTROL.test(valor))
62
+ incumple("no puede llevar caracteres de control");
63
+ if (CARACTERES_RESERVADOS.test(valor))
64
+ incumple('no puede llevar ninguno de < > : " | ? *');
65
+ if (/[. ]$/.test(valor))
66
+ incumple("no puede terminar en punto ni en espacio");
67
+ if (NOMBRES_RESERVADOS_WINDOWS.test(valor)) {
68
+ incumple("es un nombre reservado de Windows (CON, PRN, AUX, NUL, CONIN$, CONOUT$, COM1–COM9, LPT1–LPT9)");
69
+ }
70
+ return valor;
71
+ }
18
72
  // ─── Proyectos ──────────────────────────────────────────────────────────────
19
73
  // Los proyectos no se declaran en ninguna parte: son las carpetas que existen. Antes había
20
74
  // una tabla fija con los proyectos de quien escribió el código, que no restringía nada
@@ -62,6 +116,7 @@ export function requireProjectFolder(project) {
62
116
  // La ruta de una tarea SIEMPRE se arma aquí, en un solo lugar.
63
117
  // Estructura: <ORQUESTADOR_DOCS_PATH>/<PROYECTO>/<subcarpeta de tareas>/<tarea>
64
118
  export function taskFolderPath(carpetaProyecto, taskName) {
119
+ validarSegmento(taskName, "task_name");
65
120
  return path.join(requireBasePath(DOCS), carpetaProyecto, tasksSubdir(), taskName);
66
121
  }
67
122
  export function resolveTaskFolder(project, taskName) {
@@ -74,16 +129,113 @@ export function tasksRootPath(carpetaProyecto) {
74
129
  }
75
130
  // Resuelve la ruta absoluta para read/write. Prioriza project+task_name+file_name;
76
131
  // si no, cae al modo legacy con file_path relativo a la Documentación Central.
132
+ //
133
+ // Una ruta relativa hecha de segmentos portables. Cada segmento se valida por separado para que
134
+ // una subcarpeta legítima como "04 - Ejecución/L01 - x.md" siga valiendo.
135
+ function validarRutaRelativa(valor, campo, relativaA) {
136
+ if (path.isAbsolute(valor)) {
137
+ throw new Error(`${campo} no puede ser una ruta absoluta ("${valor}"): es relativo a ${relativaA}.`);
138
+ }
139
+ for (const segmento of valor.split(/[\\/]/))
140
+ validarSegmento(segmento, campo);
141
+ }
142
+ // Con file_name la ruta tiene que quedar dentro de la carpeta de la tarea, no solo dentro de
143
+ // la base: antes "../otra-tarea/x.md" escribía en otra tarea y la comprobación de la tool,
144
+ // que solo miraba la base, lo dejaba pasar.
145
+ //
146
+ // Con file_path basta con no salir de la base (la restricción a carpetas de tarea llega en la
147
+ // Etapa 1), pero sus segmentos también tienen que ser portables: antes creaba dentro de la
148
+ // Documentación un "CON", un "nul.md" o un flujo alterno "nota.md:secreto", que el Explorador
149
+ // y OneDrive no saben manejar.
77
150
  export function resolveDocPath(args) {
78
- const project = args?.project ? String(args.project) : "";
79
- const taskName = args?.task_name ? String(args.task_name) : "";
80
- const fileName = args?.file_name ? String(args.file_name) : "";
151
+ const { project = "", task_name: taskName = "", file_name: fileName = "", file_path: filePath = "" } = args;
81
152
  if (project && taskName && fileName) {
82
- return path.join(resolveTaskFolder(project, taskName), fileName);
153
+ const carpetaTarea = resolveTaskFolder(project, taskName);
154
+ validarRutaRelativa(fileName, "file_name", "la carpeta de la tarea");
155
+ const ruta = path.join(carpetaTarea, fileName);
156
+ if (!estaDentroReal(carpetaTarea, ruta)) {
157
+ throw new Error(`Acceso denegado: file_name "${fileName}" queda fuera de la carpeta de la tarea ${carpetaTarea}.`);
158
+ }
159
+ return ruta;
83
160
  }
84
- if (args?.file_path) {
85
- return path.join(requireBasePath(DOCS), String(args.file_path));
161
+ if (filePath) {
162
+ const docsBase = requireBasePath(DOCS);
163
+ validarRutaRelativa(filePath, "file_path", "la Documentación Central");
164
+ const ruta = path.join(docsBase, filePath);
165
+ if (!estaDentroReal(docsBase, ruta)) {
166
+ throw new Error(`Acceso denegado: file_path queda fuera de ${docsBase}.`);
167
+ }
168
+ return ruta;
86
169
  }
87
170
  throw new Error("Debes proporcionar (project + task_name + file_name) o bien file_path.");
88
171
  }
172
+ // ─── Repos ──────────────────────────────────────────────────────────────────
173
+ // Ruta de un archivo dentro de UN repo. Antes la comprobación miraba la carpeta de repos
174
+ // entera, y desde repoA "../repoB/.env" pasaba: estaba dentro de la carpeta de repos. El repo
175
+ // se resuelve a su ruta real para que un repo que es un enlace a otra unidad siga valiendo.
176
+ export function resolveRepoPath(repoName, filePath) {
177
+ validarSegmento(repoName, "repo_name");
178
+ const repo = path.join(requireBasePath(REPOS), repoName);
179
+ const repoReal = rutaReal(repo);
180
+ const ruta = path.join(repo, filePath);
181
+ if (!estaDentroReal(repoReal, ruta)) {
182
+ throw new Error(`Acceso denegado: file_path "${filePath}" queda fuera del repositorio ${repoName}.`);
183
+ }
184
+ return ruta;
185
+ }
186
+ // ─── Lectura acotada ────────────────────────────────────────────────────────
187
+ // Lo que leen las tools va entero al contexto del modelo. Un volcado de varios megas o un
188
+ // binario no le sirven de nada y se comen la conversación, así que se cortan antes de leer.
189
+ export const LIMITE_LECTURA_BYTES = 1_048_576;
190
+ // La misma heurística que usa git para decidir si un archivo es binario.
191
+ const BYTES_PARA_DETECTAR_BINARIO = 8000;
192
+ // Se lee del descriptor y no de la ruta: tipo, tamaño y contenido salen del mismo archivo
193
+ // abierto. Con statSync seguido de readFileSync, un archivo que crecía entre las dos llamadas
194
+ // se leía entero, sin tope. Y nunca se piden más de LIMITE + 1 bytes, que es lo justo para
195
+ // saber si se pasa. O_NONBLOCK, que no existe en Windows, evita que abrir un FIFO se quede
196
+ // esperando a que alguien escriba en él.
197
+ //
198
+ // Riesgo aceptado: la contención se comprueba sobre la ruta real y aquí se abre la ruta tal
199
+ // cual, así que cambiar un enlace justo entre las dos cosas abriría otro destino. Cerrarlo
200
+ // exige abrir sin seguir enlaces componente a componente, que Node no ofrece de forma portable.
201
+ export function leerTextoAcotado(ruta) {
202
+ let descriptor;
203
+ try {
204
+ descriptor = fs.openSync(ruta, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
205
+ }
206
+ catch (error) {
207
+ if (error?.code === "EISDIR")
208
+ throw new Error(`${ruta} no es un archivo: es una carpeta.`);
209
+ throw error;
210
+ }
211
+ try {
212
+ const info = fs.fstatSync(descriptor);
213
+ if (!info.isFile()) {
214
+ throw new Error(`${ruta} no es un archivo normal: es una carpeta, un dispositivo o una tubería.`);
215
+ }
216
+ if (info.size > LIMITE_LECTURA_BYTES) {
217
+ throw new Error(`${ruta} pesa ${info.size} bytes y el límite de lectura es 1 MB (${LIMITE_LECTURA_BYTES} bytes).`);
218
+ }
219
+ const buffer = Buffer.alloc(LIMITE_LECTURA_BYTES + 1);
220
+ let leidos = 0;
221
+ while (leidos < buffer.length) {
222
+ const bytes = fs.readSync(descriptor, buffer, leidos, buffer.length - leidos, null);
223
+ if (bytes === 0)
224
+ break;
225
+ leidos += bytes;
226
+ }
227
+ if (leidos > LIMITE_LECTURA_BYTES) {
228
+ throw new Error(`${ruta} creció mientras se leía y pasa del límite de lectura de 1 MB (${LIMITE_LECTURA_BYTES} bytes).`);
229
+ }
230
+ const contenido = buffer.subarray(0, leidos);
231
+ if (contenido.subarray(0, BYTES_PARA_DETECTAR_BINARIO).includes(0)) {
232
+ throw new Error(`${ruta} parece binario: tiene un byte NUL en sus primeros ${BYTES_PARA_DETECTAR_BINARIO} bytes. ` +
233
+ `Estas tools solo leen texto.`);
234
+ }
235
+ return contenido.toString("utf-8");
236
+ }
237
+ finally {
238
+ fs.closeSync(descriptor);
239
+ }
240
+ }
89
241
  //# sourceMappingURL=paths.js.map
@@ -0,0 +1,92 @@
1
+ import { z } from "zod";
2
+ export declare const startTaskShape: {
3
+ project: z.ZodString;
4
+ task_name: z.ZodString;
5
+ initial_context: z.ZodString;
6
+ crear_proyecto: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
7
+ };
8
+ export declare const getPhasePromptShape: {
9
+ phase: z.ZodNumber;
10
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
11
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
12
+ };
13
+ export declare const getActiveTaskShape: {
14
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
15
+ };
16
+ export declare const listTasksShape: {
17
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
18
+ };
19
+ export declare const switchTaskShape: {
20
+ project: z.ZodString;
21
+ task_name: z.ZodString;
22
+ };
23
+ export declare const readCentralDocShape: {
24
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
25
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
26
+ file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
27
+ file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
28
+ };
29
+ export declare const writeCentralDocShape: {
30
+ content: z.ZodString;
31
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
32
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
33
+ file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
34
+ file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
35
+ };
36
+ export declare const readCrossRepoShape: {
37
+ repo_name: z.ZodString;
38
+ file_path: z.ZodString;
39
+ };
40
+ declare const SHAPES: {
41
+ start_task: {
42
+ project: z.ZodString;
43
+ task_name: z.ZodString;
44
+ initial_context: z.ZodString;
45
+ crear_proyecto: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
46
+ };
47
+ get_phase_prompt: {
48
+ phase: z.ZodNumber;
49
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
50
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
51
+ };
52
+ get_active_task: {
53
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
54
+ };
55
+ list_tasks: {
56
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
57
+ };
58
+ switch_task: {
59
+ project: z.ZodString;
60
+ task_name: z.ZodString;
61
+ };
62
+ read_central_doc: {
63
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
64
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
65
+ file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
66
+ file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
67
+ };
68
+ write_central_doc: {
69
+ content: z.ZodString;
70
+ project: z.ZodOptional<z.ZodNullable<z.ZodString>>;
71
+ task_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
72
+ file_name: z.ZodOptional<z.ZodNullable<z.ZodString>>;
73
+ file_path: z.ZodOptional<z.ZodNullable<z.ZodString>>;
74
+ };
75
+ read_cross_repo: {
76
+ repo_name: z.ZodString;
77
+ file_path: z.ZodString;
78
+ };
79
+ };
80
+ type NombreTool = keyof typeof SHAPES;
81
+ type SinNulos<T> = {
82
+ [K in keyof T]: Exclude<T[K], null>;
83
+ };
84
+ export type LlamadaValidada = {
85
+ [K in NombreTool]: {
86
+ tool: K;
87
+ args: SinNulos<z.output<z.ZodObject<(typeof SHAPES)[K]>>>;
88
+ };
89
+ }[NombreTool];
90
+ export declare function validarLlamada(tool: string, args: unknown): LlamadaValidada;
91
+ export {};
92
+ //# sourceMappingURL=schemas.d.ts.map
@@ -0,0 +1,105 @@
1
+ // Los argumentos de cada tool, declarados una sola vez con zod.
2
+ //
3
+ // Antes cada tool leía sus argumentos con String(args?.x), que no valida nada: sin task_name,
4
+ // String(undefined) creaba la tarea "undefined/" y la dejaba activa. Ahora todo pasa por aquí
5
+ // antes de usarse, y el error nombra el campo.
6
+ //
7
+ // Se exportan shapes (los campos sueltos) y no z.object(...): es lo que recibe registerTool
8
+ // del SDK, al que se pasarán tal cual en la migración a McpServer (Etapa 2).
9
+ //
10
+ // Los opcionales son .nullish() y null cuenta como ausente: algunos clientes con modelos en
11
+ // modo estricto mandan null en todo campo opcional. Un número en texto (phase: "3") no se
12
+ // convierte: se rechaza con un mensaje que dice qué se espera. Es raro, el modelo lo corrige
13
+ // al primer intento, y convertirlo complicaría el JSON Schema que se generará desde aquí.
14
+ import { z } from "zod";
15
+ export const startTaskShape = {
16
+ project: z.string().trim().min(1, "Falta `project`."),
17
+ task_name: z.string().min(1, "Falta `task_name`."),
18
+ initial_context: z.string().min(1, "Falta `initial_context`."),
19
+ crear_proyecto: z.boolean().nullish(),
20
+ };
21
+ export const getPhasePromptShape = {
22
+ phase: z.number().int(),
23
+ project: z.string().nullish(),
24
+ task_name: z.string().nullish(),
25
+ };
26
+ export const getActiveTaskShape = {
27
+ project: z.string().nullish(),
28
+ };
29
+ export const listTasksShape = {
30
+ project: z.string().nullish(),
31
+ };
32
+ export const switchTaskShape = {
33
+ project: z.string().trim().min(1, "Falta `project`."),
34
+ task_name: z.string().min(1, "Falta `task_name`."),
35
+ };
36
+ // Los cuatro son opcionales por separado: qué combinación vale lo decide resolveDocPath.
37
+ export const readCentralDocShape = {
38
+ project: z.string().nullish(),
39
+ task_name: z.string().nullish(),
40
+ file_name: z.string().nullish(),
41
+ file_path: z.string().nullish(),
42
+ };
43
+ export const writeCentralDocShape = {
44
+ ...readCentralDocShape,
45
+ content: z.string(),
46
+ };
47
+ export const readCrossRepoShape = {
48
+ repo_name: z.string().min(1, "Falta `repo_name`."),
49
+ file_path: z.string().min(1, "Falta `file_path`."),
50
+ };
51
+ const SHAPES = {
52
+ start_task: startTaskShape,
53
+ get_phase_prompt: getPhasePromptShape,
54
+ get_active_task: getActiveTaskShape,
55
+ list_tasks: listTasksShape,
56
+ switch_task: switchTaskShape,
57
+ read_central_doc: readCentralDocShape,
58
+ write_central_doc: writeCentralDocShape,
59
+ read_cross_repo: readCrossRepoShape,
60
+ };
61
+ // Lo que se espera de cada tipo, dicho como se le diría al modelo para que corrija la llamada.
62
+ // El único número es `phase`, de ahí el ejemplo.
63
+ const TIPOS = {
64
+ string: "un texto",
65
+ number: "un número, por ejemplo 3",
66
+ int: "un número entero, por ejemplo 3",
67
+ boolean: "true o false",
68
+ object: "un objeto",
69
+ };
70
+ function describirProblema(issue, recibidos) {
71
+ const campo = issue.path.map(String).join(".");
72
+ if (!campo)
73
+ return "los argumentos tienen que ser un objeto.";
74
+ const nombre = `\`${campo}\``;
75
+ // null también es "falta": en un opcional se tolera, en un obligatorio es lo mismo que no venir.
76
+ if (issue.code === "invalid_type") {
77
+ return recibidos[campo] == null
78
+ ? `Falta ${nombre}.`
79
+ : `${nombre} debe ser ${TIPOS[issue.expected] ?? issue.expected}.`;
80
+ }
81
+ // Los mínimos llevan su mensaje en español en el propio shape ("Falta `task_name`.").
82
+ if (issue.code === "too_small")
83
+ return issue.message;
84
+ // Los máximos no: el único que puede saltar es el del entero seguro de `phase`.
85
+ if (issue.code === "too_big")
86
+ return `${nombre} es demasiado grande.`;
87
+ return `${nombre}: ${issue.message}.`;
88
+ }
89
+ export function validarLlamada(tool, args) {
90
+ if (!Object.hasOwn(SHAPES, tool)) {
91
+ throw new Error(`Unknown tool: ${tool}`);
92
+ }
93
+ const resultado = z.object(SHAPES[tool]).safeParse(args ?? {});
94
+ if (!resultado.success) {
95
+ const recibidos = (args && typeof args === "object" ? args : {});
96
+ const problemas = resultado.error.issues.map((issue) => describirProblema(issue, recibidos));
97
+ throw new Error(`Argumentos no válidos. ${problemas.join(" ")}`);
98
+ }
99
+ // null cuenta como ausente: se quita aquí, una vez, para que ninguna tool tenga que
100
+ // distinguir entre un campo que no vino y uno que vino vacío.
101
+ const sinNulos = Object.fromEntries(Object.entries(resultado.data).filter(([, valor]) => valor !== null));
102
+ // El cast es seguro: los datos los acaba de producir el shape de esta misma tool.
103
+ return { tool, args: sinNulos };
104
+ }
105
+ //# sourceMappingURL=schemas.js.map