webscope-mcp 0.2.0__py3-none-any.whl

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.
webscope.py ADDED
@@ -0,0 +1,695 @@
1
+ #!/usr/bin/env python3
2
+ """Webscope: servidor MCP (stdio, JSON-RPC 2.0, solo biblioteca estándar) que expone los scripts de un proyecto con salidas cortas.
3
+
4
+ Uso: python3 -I webscope.py --manifest <ruta/webscope.json> (o WEBSCOPE_MANIFEST=<ruta/webscope.json>)
5
+ Herramientas: buscar (archivo:línea con tope), leer (solo el símbolo de un archivo) y correr (scripts de solo lectura del
6
+ manifiesto, descubiertos por niveles). Cada salida se recorta a cabeza y cola; lo que escribe archivos no se expone.
7
+ También expone el prompt `recorrido`, los recorridos guardados y las capturas como recursos, y avisa del progreso de
8
+ los scripts largos cuando el cliente manda `progressToken`.
9
+ Los scripts reciben solo PATH, HOME, LANG, LC_ALL, TMPDIR y las variables que el manifiesto declara en `env_permitidas`.
10
+ """
11
+ import fnmatch
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ INSTRUCCIONES = ("Antes de Bash, grep o Read en este proyecto usa estas herramientas: devuelven menos. "
20
+ "Dónde se usa X: buscar (formato=conciso si solo quieres los archivos). "
21
+ "Una función, clase, sección de un .md (por su título) o un log (contiene, cola): leer; no leas el archivo entero. "
22
+ "Para lo que cubre un script del proyecto: correr(nombre, args) con uno de la lista de abajo, sin pedir «lista».")
23
+ ENV_BASE = ("PATH", "HOME", "LANG", "LC_ALL", "TMPDIR")
24
+ ADAPTADORES = Path(__file__).resolve().parent / "webscope_adaptadores"
25
+ AVISO = 5 # segundos entre avisos de progreso de un script largo
26
+
27
+
28
+ def cargar(ruta=None):
29
+ """Lee el manifiesto en `ruta` y devuelve su configuración con la raíz resuelta; sin `ruta`, raíz = carpeta actual y sin scripts."""
30
+ if ruta is None:
31
+ cfg = {"root": Path.cwd().resolve(), "manifiesto": ""}
32
+ else:
33
+ ruta = Path(ruta).resolve()
34
+ cfg = json.loads(ruta.read_text())
35
+ cfg["root"] = (ruta.parent / cfg.get("root", ".")).resolve()
36
+ cfg["manifiesto"] = str(ruta)
37
+ cfg.setdefault("recorte", {"cabeza": 12, "cola": 48})
38
+ cfg.setdefault("scripts", {})
39
+ cfg.setdefault("buscar", {})
40
+ cfg.setdefault("tope_tokens", 5000)
41
+ return cfg
42
+
43
+
44
+ def run(cfg, argv, timeout=300, cabeza=None, cola=None):
45
+ """Corre `argv` desde la raíz del proyecto y devuelve `exit N` y la salida recortada a `cabeza` + `cola` líneas."""
46
+ cabeza = cfg["recorte"]["cabeza"] if cabeza is None else cabeza
47
+ cola = cfg["recorte"]["cola"] if cola is None else cola
48
+ permitidas = (*ENV_BASE, *cfg.get("env_permitidas", []))
49
+ env = {k: os.environ[k] for k in permitidas if k in os.environ}
50
+ proc = subprocess.Popen([str(a) for a in argv], cwd=cfg["root"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
51
+ errors="replace", stdin=subprocess.DEVNULL,
52
+ env={**env, "WEBSCOPE_RAIZ": str(cfg["root"]), "WEBSCOPE_MANIFIESTO": cfg.get("manifiesto", "")})
53
+ pasado = 0
54
+ while True:
55
+ try:
56
+ stdout, stderr = proc.communicate(timeout=min(AVISO, timeout - pasado))
57
+ break
58
+ except subprocess.TimeoutExpired:
59
+ pasado += AVISO
60
+ if pasado >= timeout:
61
+ proc.kill()
62
+ proc.communicate()
63
+ raise
64
+ if cfg.get("_avisar"):
65
+ cfg["_avisar"](pasado, f"sigue corriendo: {pasado} s")
66
+ lines = (stdout + stderr).strip().splitlines()
67
+ if len(lines) > cabeza + cola:
68
+ lines = lines[:cabeza] + [f"… {len(lines) - cabeza - cola} líneas omitidas …"] + lines[-cola:]
69
+ return "\n".join([f"exit {proc.returncode}"] + lines)
70
+
71
+
72
+ SECRETOS = (".env", ".env.*", "*.pem", "*.key", "*.p12", "id_rsa*", "id_ed25519*", "auth.json", "credentials*")
73
+
74
+
75
+ def secreto(nombre):
76
+ """True si el nombre de archivo suele guardar claves (`.env`, llaves, credenciales), salvo las plantillas `.example`."""
77
+ return not nombre.endswith(".example") and any(fnmatch.fnmatch(nombre, p) for p in SECRETOS)
78
+
79
+
80
+ def dentro(cfg, ruta):
81
+ """Devuelve la ruta resuelta si cuelga de la raíz del proyecto y no es un archivo de claves; si no, None."""
82
+ resuelta = (cfg["root"] / ruta).resolve()
83
+ if secreto(resuelta.name):
84
+ return None
85
+ return resuelta if resuelta == cfg["root"] or cfg["root"] in resuelta.parents else None
86
+
87
+
88
+ def formato_valido(formato):
89
+ """Devuelve True si `formato` es «conciso»; False si es «detallado»; si no, falla."""
90
+ if formato not in ("conciso", "detallado"):
91
+ raise ValueError("formato debe ser «conciso» o «detallado»")
92
+ return formato == "conciso"
93
+
94
+
95
+ def _con_contexto(cfg, archivo, hits, contexto, todas):
96
+ """Filas de `hits` [(n, texto)] de `archivo` con `contexto` líneas alrededor: `n:` las coincidencias de `todas`, `n-` el resto."""
97
+ lineas = (cfg["root"] / archivo).read_text(errors="ignore").splitlines()
98
+ marcadas, filas, ultima = {int(n) for n, _ in todas}, [], 0
99
+ for n in sorted(int(n) for n, _ in hits):
100
+ desde, hasta = max(n - contexto, ultima + 1), min(n + contexto, len(lineas))
101
+ if filas and desde > ultima + 1:
102
+ filas.append(" --")
103
+ filas += [f" {k}{':' if k in marcadas else '-'} {lineas[k - 1].strip()[:80]}".rstrip() for k in range(desde, hasta + 1)]
104
+ ultima = max(ultima, hasta)
105
+ return filas
106
+
107
+
108
+ def tool_buscar(cfg, texto, donde="", tope=15, formato="detallado", contexto=0, regex=False):
109
+ """Líneas con `texto` agrupadas por archivo, primero los más referenciados, hasta `tope` líneas; `donde` = carpeta o archivo relativo a la raíz.
110
+
111
+ `formato` conciso = solo los 5 archivos con más coincidencias; `contexto` = líneas alrededor de cada una; `regex` = `texto` es una expresión regular extendida.
112
+ """
113
+ conciso = formato_valido(formato)
114
+ if not isinstance(contexto, int) or not 0 <= contexto <= 5:
115
+ raise ValueError("contexto debe ser un entero de 0 a 5")
116
+ if not isinstance(regex, bool):
117
+ raise ValueError("regex debe ser true o false")
118
+ base = dentro(cfg, donde) if donde else None
119
+ if donde and (base is None or not base.exists()):
120
+ raise ValueError(f"{donde} no es una carpeta ni un archivo del proyecto")
121
+ dirs = [base] if base else [cfg["root"] / d for d in cfg["buscar"].get("dirs", ["."])]
122
+ exts = [] if base else [f"*.{e}" for e in cfg["buscar"].get("extensiones", [])]
123
+ proc = subprocess.run(["grep", "-rnIiEH" if regex else "-rnIiFH", "--exclude-dir=node_modules", "--exclude-dir=vendor", "--exclude-dir=.git",
124
+ *[f"--exclude={p}" for p in SECRETOS], *[f"--include={e}" for e in exts], "-e", texto, *map(str, dirs)],
125
+ capture_output=True, text=True, errors="replace", stdin=subprocess.DEVNULL)
126
+ if proc.returncode > 1:
127
+ raise ValueError(f"grep: {proc.stderr.strip().splitlines()[-1] if proc.stderr.strip() else 'error'}")
128
+ grupos = {}
129
+ for fila in proc.stdout.splitlines():
130
+ if fila.count(":") >= 2:
131
+ f, n, t = fila.split(":", 2)
132
+ grupos.setdefault(str(Path(f).relative_to(cfg["root"])), []).append((n, t.strip()[:80]))
133
+ total, salida, mostradas, k = sum(map(len, grupos.values())), [], 0, 0
134
+ ranking = sorted(grupos.items(), key=lambda g: (-len(g[1]), g[0]))
135
+ if conciso:
136
+ resumen = [f"{a}:{h[0][0]} ({len(h)})" for a, h in ranking[:5]]
137
+ resto = len(ranking) - 5
138
+ return "\n".join([f"{total} coincidencias en {len(grupos)} archivos"] + resumen + ([f"… otros {resto} archivos"] if resto > 0 else []))
139
+ while k < len(ranking) and mostradas < tope:
140
+ archivo, hits = ranking[k]
141
+ hits = hits[:tope - mostradas]
142
+ mostradas += len(hits)
143
+ k += 1
144
+ if contexto:
145
+ salida += [f"{archivo} ({len(ranking[k - 1][1])})"] + _con_contexto(cfg, archivo, hits, contexto, ranking[k - 1][1])
146
+ elif len(ranking[k - 1][1]) == 1:
147
+ salida.append(f"{archivo}:{hits[0][0]} {hits[0][1]}")
148
+ else:
149
+ salida += [f"{archivo} ({len(ranking[k - 1][1])})"] + [f" {n} {t}" for n, t in hits]
150
+ if k < len(ranking):
151
+ salida.append(f"… otros {len(ranking) - k} archivos: " + ", ".join(f"{a} ({len(h)})" for a, h in ranking[k:k + 8])
152
+ + (", …" if len(ranking) - k > 8 else ""))
153
+ return "\n".join([f"{total} coincidencias en {len(grupos)} archivos" + (f", primeras {mostradas}" if total > mostradas else "")] + salida)
154
+
155
+
156
+ LOGS = {".log", ".txt", ".out"}
157
+ DEFINE = r"(\b(function|def|class|interface|enum|trait|type)\s+{0}\b|\b(const|let|var)\s+{0}\s*[=:])"
158
+ METODO = r"^\s*(async\s+)?{0}\s*[(<]"
159
+ ASIGNACION = re.compile(r"\s*(export\s+)?(declare\s+)?(const|let|var|type)\b")
160
+ CONTINUA = (".", "?", ":", "&&", "||", "+", "-", "*", "/", "|", "&", "=")
161
+
162
+
163
+ def _seccion(lineas, titulo):
164
+ """Rango (inicio, fin exclusivo) de la sección markdown cuyo encabezado es `titulo` (igual, empieza por o contiene), con sus subsecciones."""
165
+ cerca, encabezados = "", []
166
+ for n, linea in enumerate(lineas):
167
+ marca = re.match(r"\s*(`{3,}|~{3,})", linea)
168
+ if marca:
169
+ if not cerca:
170
+ cerca = marca[1]
171
+ elif marca[1][0] == cerca[0] and len(marca[1]) >= len(cerca) and linea.strip() == marca[1]:
172
+ cerca = ""
173
+ elif not cerca and (m := re.match(r"(#{1,6})\s+(.*?)(?:\s*\{#[^}]*\})?\s*$", linea)):
174
+ encabezados.append((n, len(m[1]), m[2].lower()))
175
+ q = titulo.lower().strip()
176
+ for coincide in (lambda t: t == q, lambda t: t.startswith(q), lambda t: q in t):
177
+ for k, (n, nivel, t) in enumerate(encabezados):
178
+ if coincide(t):
179
+ return n, next((m for m, nv, _ in encabezados[k + 1:] if nv <= nivel), len(lineas))
180
+ raise LookupError(f"no hay una sección «{titulo}»")
181
+
182
+
183
+ def _de_log(lineas, rel, cola, contiene, max_lineas):
184
+ """Líneas de un log numeradas y a 200 caracteres: las que contienen `contiene` y/o las últimas `cola` (40 si no hay filtro)."""
185
+ filas = [(n + 1, l) for n, l in enumerate(lineas) if contiene.lower() in l.lower()]
186
+ total, tope = len(filas), min(cola or (max_lineas if contiene else 40), max_lineas)
187
+ filas = filas[-tope:]
188
+ if contiene:
189
+ cabeza = f"{rel}: {total} líneas con «{contiene}» de {len(lineas)}" + (f", últimas {len(filas)}" if total > len(filas) else "")
190
+ else:
191
+ cabeza = f"{rel}:{filas[0][0] if filas else 0}-{filas[-1][0] if filas else 0} de {len(lineas)}"
192
+ return "\n".join([cabeza] + [f"{n}: {l[:200]}" for n, l in filas])
193
+
194
+
195
+ def _salta_cadena(texto, n, comilla):
196
+ """Desde `n` (tras la comilla de apertura) devuelve (índice tras el cierre, False) o (índice tras `${`, True) en una plantilla."""
197
+ while n < len(texto) and texto[n] != comilla:
198
+ if texto[n] == "\\":
199
+ n += 1
200
+ elif comilla == "`" and texto[n:n + 2] == "${":
201
+ return n + 2, True
202
+ n += 1
203
+ return n + 1, False
204
+
205
+
206
+ def _fin_llaves(lineas, i, php):
207
+ """Índice (exclusivo) de la línea donde acaba la declaración de `lineas[i]`: cuenta (), [] y {} fuera de cadenas, comentarios y regex.
208
+
209
+ shortcut: sin heredoc con etiqueta entre comillas ni regex tras `)`; mejorar con un tokenizador real si un símbolo sale cortado.
210
+ """
211
+ texto = "\n".join(lineas[i:])
212
+ asignacion = bool(ASIGNACION.match(lineas[i]))
213
+ n, nivel, linea, previo, plantillas, abierto = 0, 0, i, "", [], False
214
+
215
+ def avanza(nuevo):
216
+ nonlocal linea
217
+ linea += texto.count("\n", n, nuevo)
218
+ return nuevo
219
+
220
+ while n < len(texto):
221
+ c, par = texto[n], texto[n:n + 2]
222
+ if c == "\n":
223
+ sigue = next((l.strip() for l in lineas[linea + 1:] if l.strip()), "")
224
+ if asignacion and nivel == 0 and previo and previo[-1] not in "=,(" and previo[-1] not in "+-*/&|?:" \
225
+ and previo != "=>" and not sigue.startswith(CONTINUA):
226
+ return linea + 1
227
+ linea += 1
228
+ elif par == "//" or (php and c == "#" and par != "#["):
229
+ n = (texto.find("\n", n) if "\n" in texto[n:] else len(texto)) - 1
230
+ elif par == "/*":
231
+ n = avanza(texto.find("*/", n + 2) + 2 if "*/" in texto[n:] else len(texto)) - 1
232
+ elif php and par == "<<" and (m := re.match(r"<<<\s*['\"]?(\w+)['\"]?\s*\n", texto[n:])):
233
+ cierre = re.search(rf"\n\s*{m[1]}\b", texto[n + m.end() - 1:])
234
+ n = avanza(n + m.end() - 1 + (cierre.end() if cierre else len(texto))) - 1
235
+ previo = "'"
236
+ elif c in "'\"" or (c == "`" and not php):
237
+ nuevo, plantilla = _salta_cadena(texto, n + 1, c)
238
+ n = avanza(nuevo) - 1
239
+ if plantilla:
240
+ plantillas.append(nivel)
241
+ nivel += 1
242
+ previo = "'"
243
+ elif c == "/" and not php and previo[-1:] in ("", *"(,=:[!&|?{};") and par not in ("//", "/*"):
244
+ n += 1
245
+ clase = False
246
+ while n < len(texto) and (texto[n] != "/" or clase) and texto[n] != "\n":
247
+ clase = clase if texto[n] != "[" and texto[n] != "]" else texto[n] == "["
248
+ n += 2 if texto[n] == "\\" else 1
249
+ previo = "'"
250
+ elif c in "{([":
251
+ nivel, abierto, previo = nivel + 1, abierto or c == "{", c
252
+ elif c in ")]":
253
+ nivel, previo = nivel - 1, c
254
+ elif c == "}":
255
+ nivel, previo = nivel - 1, c
256
+ if plantillas and plantillas[-1] == nivel:
257
+ plantillas.pop()
258
+ nuevo, plantilla = _salta_cadena(texto, n + 1, "`")
259
+ n = avanza(nuevo) - 1
260
+ if plantilla:
261
+ plantillas.append(nivel)
262
+ nivel += 1
263
+ elif not asignacion and abierto and nivel == 0 and not texto[n + 1:].split("\n", 1)[0].lstrip().startswith("{"):
264
+ return linea + 1
265
+ elif c == ";" and nivel == 0:
266
+ return linea + 1
267
+ elif not c.isspace():
268
+ previo = (previo + c)[-2:]
269
+ n += 1
270
+ return len(lineas)
271
+
272
+
273
+ def _inicio(lineas, simbolo):
274
+ """Índice de la línea que define `simbolo`: primero una declaración (function, class, const…), si no un método sin palabra clave."""
275
+ nombre = re.escape(simbolo)
276
+ for patron in (DEFINE.format(nombre), METODO.format(nombre)):
277
+ for i, linea in enumerate(lineas):
278
+ if not linea.lstrip().startswith(("//", "*", "/*", "#")) and re.search(patron, linea):
279
+ return i
280
+ raise LookupError(f"no hay {simbolo}")
281
+
282
+
283
+ INDICE = re.compile(r"^\s*(?:export\s+(?:default\s+)?)?(?:(?:public|private|protected|static|abstract|final|readonly|async)\s+)*"
284
+ r"(?:function\*?|def|class|interface|enum|trait|type)\s+([A-Za-z_$][\w$]*)"
285
+ r"|^\s*(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\(|function\b|[A-Za-z_$][\w$]*\s*=>)")
286
+
287
+
288
+ VUE = re.compile(r"<(template|script|style)\b|(export\s+)?(const|let|function|async\s+function)\s")
289
+
290
+
291
+ def _indice(lineas, rel, ruta, max_lineas):
292
+ """Índice de un archivo: títulos de un .md, bloques y declaraciones de primer nivel de un .vue o definiciones de código, con su línea.
293
+
294
+ Sin ninguno devuelve las primeras 30 líneas numeradas.
295
+ """
296
+ if ruta.suffix == ".md":
297
+ filas = [f"{n + 1}: {l.strip()}" for n, l in enumerate(lineas) if re.match(r"#{1,6}\s", l)]
298
+ elif ruta.suffix == ".vue":
299
+ filas = [f"{n + 1}: {l.strip()[:90]}" for n, l in enumerate(lineas) if VUE.match(l)]
300
+ else:
301
+ filas = [f"{n + 1}: {l.strip()[:90]}" for n, l in enumerate(lineas) if INDICE.match(l)]
302
+ if not filas:
303
+ tope = min(30, max_lineas, len(lineas))
304
+ return "\n".join([f"{rel}: {len(lineas)} líneas, SIN ÍNDICE (no hay símbolos); primeras {tope}, el resto con lineas=«a-b»"]
305
+ + [f"{n + 1}: {lineas[n]}" for n in range(tope)])
306
+ extra = [f"… {len(filas) - max_lineas} más"] if len(filas) > max_lineas else []
307
+ return "\n".join([f"{rel}: {len(lineas)} líneas, índice"] + filas[:max_lineas] + extra)
308
+
309
+
310
+ def _rango(lineas, rel, texto, max_lineas):
311
+ """Líneas numeradas del rango `texto` («120-180» o «120»), hasta `max_lineas`."""
312
+ m = re.fullmatch(r"\s*(\d+)\s*(?:-\s*(\d+))?\s*", str(texto))
313
+ if not m or int(m[1]) < 1:
314
+ raise ValueError("lineas debe ser «inicio-fin» o «inicio», p. ej. «120-180»")
315
+ i, fin = int(m[1]) - 1, min(int(m[2] or m[1]), len(lineas))
316
+ if i >= len(lineas) or fin <= i:
317
+ raise ValueError(f"{rel} tiene {len(lineas)} líneas; el rango {texto} queda fuera")
318
+ cuerpo = [f"{n + 1}: {lineas[n]}" for n in range(i, min(fin, i + max_lineas))]
319
+ if fin - i > max_lineas:
320
+ cuerpo.append(f"… {fin - i - max_lineas} líneas más (hasta {fin})")
321
+ return "\n".join([f"{rel}:{i + 1}-{fin} de {len(lineas)}"] + cuerpo)
322
+
323
+
324
+ def tool_leer(cfg, archivo, simbolo="", max_lineas=120, cola=0, contiene="", formato="detallado", lineas=""):
325
+ """Solo un trozo de `archivo`: la función, método o clase `simbolo` (php, ts, vue, js, py), la sección `simbolo` de un .md por su título, o de un log/.txt las últimas `cola` líneas y/o las que tienen `contiene`.
326
+
327
+ `simbolo` puede ser una lista (un trozo por símbolo); `lineas` = rango «120-180»; sin `simbolo` ni `lineas`, el índice del archivo.
328
+ """
329
+ formato_valido(formato)
330
+ ruta = dentro(cfg, archivo)
331
+ if ruta is None or not ruta.is_file():
332
+ raise ValueError(f"{archivo} no es un archivo del proyecto")
333
+ texto = ruta.read_text(errors="ignore").splitlines()
334
+ rel = ruta.relative_to(cfg["root"])
335
+ if lineas:
336
+ return _rango(texto, rel, lineas, max_lineas)
337
+ if ruta.suffix in LOGS:
338
+ return _de_log(texto, rel, cola or (10 if formato == "conciso" else 0), contiene, max_lineas)
339
+ if not simbolo:
340
+ return _indice(texto, rel, ruta, max_lineas)
341
+ if isinstance(simbolo, list):
342
+ return "\n\n".join(_trozo(texto, rel, ruta, str(s), max_lineas, formato == "conciso") for s in simbolo)
343
+ return _trozo(texto, rel, ruta, simbolo, max_lineas, formato == "conciso")
344
+
345
+
346
+ def _trozo(lineas, rel, ruta, simbolo, max_lineas, conciso):
347
+ """La función, método o clase `simbolo` de `lineas` numerada, o la sección `simbolo` si es un .md."""
348
+ numerar = ruta.suffix != ".md"
349
+ try:
350
+ if ruta.suffix == ".md":
351
+ i, fin = _seccion(lineas, simbolo)
352
+ else:
353
+ i = _inicio(lineas, simbolo)
354
+ if ruta.suffix == ".py":
355
+ sangria = len(lineas[i]) - len(lineas[i].lstrip())
356
+ fin = next((j for j in range(i + 1, len(lineas)) if lineas[j].strip() and len(lineas[j]) - len(lineas[j].lstrip()) <= sangria),
357
+ len(lineas))
358
+ else:
359
+ fin = _fin_llaves(lineas, i, ruta.suffix == ".php")
360
+ except LookupError as error:
361
+ raise LookupError(f"{error} en {rel}") from None
362
+ while fin > i + 1 and not lineas[fin - 1].strip():
363
+ fin -= 1
364
+ if conciso and ruta.suffix == ".md":
365
+ titulos = [l for l in lineas[i:fin] if re.match(r"#{1,6}\s", l)]
366
+ return "\n".join([f"{rel}:{i + 1}-{fin}"] + titulos)
367
+ max_lineas = 1 if conciso else max_lineas
368
+ cuerpo = [f"{n + 1}: {lineas[n]}" if numerar else lineas[n] for n in range(i, min(fin, i + max_lineas))]
369
+ if fin - i > max_lineas:
370
+ cuerpo.append(f"… {fin - i - max_lineas} líneas más (hasta {fin})")
371
+ return "\n".join([f"{rel}:{i + 1}-{fin}"] + cuerpo)
372
+
373
+
374
+ def tool_correr(cfg, nombre="lista", args=(), detalle="nombre", escritura=False):
375
+ """Corre un script de solo lectura del manifiesto con `args`; `nombre`="lista" los enumera con su uso; `detalle`="esquema" con un script da su ficha."""
376
+ scripts = cfg["scripts"]
377
+ if nombre == "lista":
378
+ if detalle == "nombre":
379
+ return "\n".join(f"{n} {s.get('uso', '').split(';')[0][:70]}".rstrip() for n, s in scripts.items())
380
+ return "\n".join(f"{n}: {s['descripcion']}" + (f"\n uso: {s.get('uso', '')}" if detalle == "esquema" else "")
381
+ for n, s in scripts.items())
382
+ if nombre not in scripts:
383
+ raise LookupError(f"no hay el script {nombre}; correr('lista') los enumera")
384
+ s = scripts[nombre]
385
+ rechaza = s.get("rechaza", [])
386
+ if detalle == "esquema":
387
+ return f"{nombre}: {s['descripcion']}\nuso: {s.get('uso', '')}\nno admite: {', '.join(rechaza) or '-'}"
388
+ if isinstance(args, str):
389
+ raise ValueError("args debe ser una lista, p. ej. [\"a\", \"b\"]")
390
+ args = [str(a) for a in args]
391
+ for a in args:
392
+ if any(a == f or a.startswith(f + "=") for f in rechaza):
393
+ raise ValueError(f"{nombre} no admite {a} (escribiría archivos)")
394
+ if a in s.get("escribe", []) and not escritura:
395
+ raise ValueError(f"{nombre} {a} escribe en la base: usa correr_escritura")
396
+ if a in s.get("escribe", []) and os.environ.get("WEBSCOPE_ESCRITURA") not in ("1", "sin-confirmar"):
397
+ raise ValueError(f"{nombre} {a} escribe en la base: el usuario debe activar WEBSCOPE_ESCRITURA=1 en la configuración del servidor")
398
+ if (a.startswith("/") and not s.get("acepta_barra")) or ".." in Path(a).parts:
399
+ raise ValueError(f"{a}: usa rutas relativas a la raíz del proyecto, sin ..")
400
+ cmd = [sys.executable if c == "python3" else c.replace("{adaptadores}", str(ADAPTADORES)) for c in s["cmd"]]
401
+ return run(cfg, [*cmd, *args], timeout=s.get("timeout", 300), cabeza=s.get("cabeza"), cola=s.get("cola"))
402
+
403
+
404
+ def tool_correr_escritura(cfg, nombre, args):
405
+ """Corre un script del manifiesto con un argumento que escribe en la base (lo declara `escribe`); exige WEBSCOPE_ESCRITURA=1 y confirmación del usuario en cada llamada."""
406
+ return tool_correr(cfg, nombre, args, escritura=True)
407
+
408
+
409
+ S, I = {"type": "string"}, {"type": "integer"}
410
+ FORMATO = {**S, "enum": ["conciso", "detallado"], "default": "detallado", "description": "conciso = solo lo esencial"}
411
+ ARGS = {"type": "array", "items": S, "description": "un argumento por elemento; rutas relativas, sin .."}
412
+ TOOLS = {
413
+ "buscar": (tool_buscar, {"type": "object", "additionalProperties": False, "required": ["texto"], "properties": {
414
+ "texto": {**S, "minLength": 1, "description": "literal salvo regex=true"},
415
+ "donde": {**S, "description": "carpeta o archivo relativo a la raíz"},
416
+ "tope": {**I, "minimum": 1, "default": 15, "description": "máximo de líneas"}, "formato": FORMATO,
417
+ "contexto": {**I, "minimum": 0, "maximum": 5, "default": 0, "description": "líneas alrededor"},
418
+ "regex": {"type": "boolean", "default": False, "description": "texto es regex extendida"}}}),
419
+ "leer": (tool_leer, {"type": "object", "additionalProperties": False, "required": ["archivo"], "properties": {
420
+ "archivo": {**S, "description": "ruta relativa a la raíz"},
421
+ "simbolo": {"anyOf": [S, {"type": "array", "items": S}], "description": "función, clase o título; lista = varios"},
422
+ "lineas": {**S, "description": "rango «120-180»"},
423
+ "max_lineas": {**I, "minimum": 1, "default": 120},
424
+ "cola": {**I, "minimum": 0, "default": 0, "description": "logs: últimas N líneas"},
425
+ "contiene": {**S, "description": "logs: líneas con este texto"}, "formato": FORMATO}}),
426
+ "correr": (tool_correr, {"type": "object", "additionalProperties": False, "required": [], "properties": {
427
+ "nombre": {**S, "default": "lista", "description": "script o «lista»"}, "args": ARGS,
428
+ "detalle": {**S, "enum": ["nombre", "descripcion", "esquema"], "default": "nombre", "description": "esquema = ficha sin correrlo"}}}),
429
+ "correr_escritura": (tool_correr_escritura, {"type": "object", "additionalProperties": False, "required": ["nombre", "args"], "properties": {
430
+ "nombre": {**S, "description": "script que declara `escribe`"}, "args": {**ARGS, "minItems": 1}}}),
431
+ }
432
+ TITULOS = {"buscar": "Buscar en el proyecto", "leer": "Leer un trozo de archivo", "correr": "Correr un script de solo lectura",
433
+ "correr_escritura": "Correr un script que escribe"}
434
+ DESCRIPCIONES = {
435
+ "buscar": "Dónde se define o usa un texto: líneas agrupadas por archivo, primero los más citados (conciso: 5 "
436
+ "archivos). Sin distinguir mayúsculas. Úsala en vez de grep. Para ver el código usa leer. Solo lectura, dentro de la raíz; omite secretos (.env, *.key...).",
437
+ "leer": "Solo un trozo de un archivo: la función o clase `simbolo` (PHP, TS, JS, Vue, Python; lista = varias en una "
438
+ "llamada), la sección de un .md, un rango `lineas`, o de un log las últimas `cola` o las que tienen `contiene`; sin "
439
+ "nada, el índice del archivo. Úsala en vez de Read, cat o sed. Solo lectura, dentro de la raíz; no lee secretos.",
440
+ "correr": "Corre un script de solo lectura del manifiesto: código de salida y salida recortada. Sin `nombre` los "
441
+ "enumera. Úsala en vez de Bash para lo que cubre un script. Sin shell; rechaza rutas absolutas, `..` y "
442
+ "lo que escribe (va por correr_escritura).",
443
+ "correr_escritura": "Destructiva: corre un script con un argumento que escribe datos (de su `escribe`, p. ej. --borrar); no "
444
+ "se deshace. Exige WEBSCOPE_ESCRITURA=1 y confirmación del usuario en cada llamada. Úsala solo si el "
445
+ "usuario pidió el cambio; antes revisa con correr sin ese argumento.",
446
+ }
447
+
448
+
449
+ def con_tope(cfg, texto):
450
+ """Recorta `texto` a `tope_tokens` del manifiesto (4 caracteres por token, aproximado) y avisa cómo pedir menos."""
451
+ limite = cfg["tope_tokens"] * 4
452
+ if len(texto) <= limite:
453
+ return texto
454
+ return texto[:limite] + f"\n… recortado a ~{cfg['tope_tokens']} tokens; usa formato=conciso o filtra más"
455
+
456
+
457
+ def explica(nombre, args, texto):
458
+ """Antepone a `texto` una línea corta que dice qué es la respuesta de la herramienta `nombre` y qué sigue."""
459
+ simbolo, script = args.get("simbolo", ""), args.get("nombre", "lista")
460
+ if nombre == "buscar":
461
+ lead = f"Líneas del proyecto con «{str(args.get('texto', ''))[:30]}» (archivo, línea). Sigue: leer(archivo, símbolo) de la que interese."
462
+ elif Path(str(args.get("archivo", ""))).suffix in LOGS:
463
+ lead = "Líneas de un registro (log), numeradas. Sigue: afinar con contiene o cola."
464
+ elif nombre == "leer" and args.get("lineas"):
465
+ lead = "Rango de líneas numeradas. Sigue: leer(archivo, simbolo) si buscas una función entera."
466
+ elif nombre == "leer" and not simbolo:
467
+ lead = "Índice del archivo con su línea, o sus primeras líneas si no tiene símbolos. Sigue: leer(archivo, simbolo) o lineas=«a-b»."
468
+ elif nombre == "leer" and isinstance(simbolo, list):
469
+ lead = f"{len(simbolo)} trozos, uno por símbolo. Sigue: buscar el que quieras ver usado."
470
+ elif nombre == "leer" and Path(str(args.get("archivo", ""))).suffix == ".md":
471
+ lead = f"Sección «{simbolo}» del documento (hasta el siguiente título igual o mayor). Sigue: otra sección o buscar."
472
+ elif nombre == "leer":
473
+ lead = f"Código de «{simbolo}» (función o clase) con sus líneas. Sigue: buscar(\"{simbolo}\") para ver quién lo usa."
474
+ elif script == "lista":
475
+ lead = "Scripts de solo lectura del proyecto. Sigue: correr(nombre, args) directo; detalle=\"esquema\" solo si dudas de los args de uno."
476
+ elif args.get("detalle") == "esquema":
477
+ lead = f"Ficha del script «{script}». Sigue: correr(\"{script}\", args)."
478
+ else:
479
+ lead = f"Salida del script «{script}» (exit 0 = sin error). Sigue: leer o buscar lo que cite."
480
+ return f"{lead}\n{texto}"
481
+
482
+
483
+ TIPOS = {"string": (str, "texto"), "integer": (int, "entero"), "boolean": (bool, "true o false"), "array": (list, "lista")}
484
+
485
+
486
+ def invalido(esquema, valor, nombre):
487
+ """Motivo por el que `valor` no cumple `esquema` (tipos, enum, mínimos y máximos de los esquemas de TOOLS), o '' si lo cumple."""
488
+ if "anyOf" in esquema:
489
+ return "" if any(not invalido(s, valor, nombre) for s in esquema["anyOf"]) else f"{nombre} debe ser texto o lista de textos"
490
+ tipo, como = TIPOS.get(esquema.get("type"), (object, ""))
491
+ if not isinstance(valor, tipo) or (tipo is int and isinstance(valor, bool)):
492
+ return f"{nombre} debe ser {como}"
493
+ if "enum" in esquema and valor not in esquema["enum"]:
494
+ return f"{nombre} debe ser uno de {', '.join(esquema['enum'])}"
495
+ if "minimum" in esquema and valor < esquema["minimum"] or "maximum" in esquema and valor > esquema["maximum"]:
496
+ return f"{nombre} debe estar entre {esquema.get('minimum', '-∞')} y {esquema.get('maximum', '∞')}"
497
+ if len(valor) < esquema.get("minLength", esquema.get("minItems", 0)) if tipo in (str, list) else False:
498
+ return f"{nombre} no puede estar vacío"
499
+ if tipo is list and "items" in esquema:
500
+ return next((m for m in (invalido(esquema["items"], x, f"cada elemento de {nombre}") for x in valor) if m), "")
501
+ return ""
502
+
503
+
504
+ def validar(esquema, args):
505
+ """Falla con un mensaje claro si `args` no cumple el esquema de entrada de la herramienta."""
506
+ if not isinstance(args, dict):
507
+ raise ValueError("arguments debe ser un objeto")
508
+ faltan = [p for p in esquema.get("required", []) if p not in args]
509
+ sobran = [p for p in args if p not in esquema["properties"]]
510
+ motivo = (f"falta {', '.join(faltan)}" if faltan else f"no existe {', '.join(sobran)}; hay {', '.join(esquema['properties'])}" if sobran
511
+ else next((m for m in (invalido(esquema["properties"][p], v, p) for p, v in args.items()) if m), ""))
512
+ if motivo:
513
+ raise ValueError(motivo)
514
+
515
+
516
+ VERSIONES = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
517
+ SALIDA = {"type": "object", "properties": {"nota": S, "exit": {"type": ["integer", "null"]}, "salida": S}}
518
+
519
+
520
+ def estructura(texto, nota):
521
+ """`structuredContent` de correr: la nota de qué sigue, el código de la primera línea «exit N» y el resto como salida."""
522
+ m = re.match(r"exit (-?\d+)\n?", texto)
523
+ return {"nota": nota, **({"exit": int(m.group(1)), "salida": texto[m.end():]} if m else {"exit": None, "salida": texto})}
524
+
525
+
526
+ def anotaciones(nombre):
527
+ """Pistas de comportamiento de la herramienta: solo `correr_escritura` escribe; ninguna sale del proyecto."""
528
+ if nombre == "correr_escritura":
529
+ return {"readOnlyHint": False, "destructiveHint": True, "idempotentHint": False, "openWorldHint": False}
530
+ return {"readOnlyHint": True, "openWorldHint": False}
531
+
532
+
533
+ def herramientas(cfg):
534
+ """Herramientas que expone el servidor: `correr_escritura` solo si algún script del manifiesto declara `escribe`."""
535
+ escribe = any(x.get("escribe") for x in cfg.get("scripts", {}).values())
536
+ return [(n, f, s) for n, (f, s) in TOOLS.items() if n != "correr_escritura" or escribe]
537
+
538
+
539
+ RECORRIDO = Path(__file__).resolve().parent / "agents" / "recorrido.example.md"
540
+ PROMPTS = [{"name": "recorrido", "title": "Recorrer un módulo en el navegador",
541
+ "description": "Recorre en vivo un módulo del frontend (listar, crear ZZ_E2E, editar, verificar, borrar, limpiar) con "
542
+ "Chrome y los scripts de webscope, y resume solo lo que falla.",
543
+ "arguments": [{"name": "modulo", "description": "módulo del frontend, p. ej. sales/orders", "required": True},
544
+ {"name": "url", "description": "URL base de la app, p. ej. http://localhost:5173"},
545
+ {"name": "prefijo", "description": "prefijo de lo que se crea (por defecto ZZ_E2E)"}]}]
546
+
547
+
548
+ def prompt_recorrido(argumentos):
549
+ """Mensaje del prompt `recorrido`: el método del agente de ejemplo (sin su cabecera) y el pedido con sus argumentos."""
550
+ if not argumentos.get("modulo"):
551
+ raise ValueError("falta el argumento modulo")
552
+ metodo = RECORRIDO.read_text().split("---", 2)[2].strip()
553
+ pedido = (f"Walk the module `{argumentos['modulo']}`"
554
+ + (f" of the app at {argumentos['url']}" if argumentos.get("url") else "")
555
+ + f". Prefix for everything you create: {argumentos.get('prefijo') or 'ZZ_E2E'}.")
556
+ return {"description": PROMPTS[0]["description"],
557
+ "messages": [{"role": "user", "content": {"type": "text", "text": f"{metodo}\n\n## Request\n{pedido}"}}]}
558
+
559
+
560
+ def carpetas_recursos(cfg):
561
+ """Carpetas cuyos archivos se publican como recursos: {tipo: (carpeta, patrón, mimeType)}."""
562
+ carpetas = {"recorridos": (cfg["root"] / ".claude/webscope/recorridos", "*.json", "application/json")}
563
+ capturas = cfg.get("proyecto", {}).get("capturas")
564
+ if capturas and dentro(cfg, capturas):
565
+ carpetas["capturas"] = (dentro(cfg, capturas), "*.txt", "text/plain")
566
+ return carpetas
567
+
568
+
569
+ def recursos(cfg):
570
+ """Recorridos guardados y capturas del proyecto como recursos MCP (`webscope://<tipo>/<archivo>`)."""
571
+ return [{"uri": f"webscope://{tipo}/{f.name}", "name": f"{tipo}/{f.name}", "mimeType": mime,
572
+ "description": "pasos que repite e2e_chrome --repetir" if tipo == "recorridos" else "captura de un recorrido (red, pasos, tiempos)"}
573
+ for tipo, (carpeta, patron, mime) in carpetas_recursos(cfg).items() if carpeta.is_dir()
574
+ for f in sorted(carpeta.glob(patron))]
575
+
576
+
577
+ def leer_recurso(cfg, uri):
578
+ """Contenido de un recurso `webscope://<tipo>/<archivo>`, recortado al tope de tokens."""
579
+ m = re.fullmatch(r"webscope://(\w+)/([\w.-]+)", str(uri))
580
+ carpetas = carpetas_recursos(cfg)
581
+ if not m or m.group(1) not in carpetas:
582
+ raise LookupError(f"recurso desconocido {uri}")
583
+ carpeta, patron, mime = carpetas[m.group(1)]
584
+ ruta = carpeta / m.group(2)
585
+ if not fnmatch.fnmatch(ruta.name, patron) or not ruta.is_file():
586
+ raise LookupError(f"recurso desconocido {uri}")
587
+ return {"contents": [{"uri": uri, "mimeType": mime, "text": con_tope(cfg, ruta.read_text(errors="replace"))}]}
588
+
589
+
590
+ def handle(cfg, message):
591
+ """Responde una petición JSON-RPC; devuelve None si es una notificación."""
592
+ method, mid, params = message.get("method"), message.get("id"), message.get("params") or {}
593
+ if mid is None:
594
+ return None
595
+ if not isinstance(params, dict):
596
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": "params debe ser un objeto"}}
597
+ if method == "initialize":
598
+ pedida = params.get("protocolVersion")
599
+ result = {"protocolVersion": pedida if pedida in VERSIONES else VERSIONES[0],
600
+ "capabilities": {"tools": {}, "resources": {}, **({"prompts": {}} if RECORRIDO.is_file() else {})},
601
+ "serverInfo": {"name": "webscope", "version": "0.2.0"},
602
+ "instructions": f"{INSTRUCCIONES}\nScripts (nombre y args):\n{tool_correr(cfg)}"}
603
+ elif method == "ping":
604
+ result = {}
605
+ elif method == "resources/list":
606
+ result = {"resources": recursos(cfg)}
607
+ elif method == "resources/templates/list":
608
+ result = {"resourceTemplates": []}
609
+ elif method == "resources/read":
610
+ try:
611
+ result = leer_recurso(cfg, params.get("uri"))
612
+ except LookupError as error:
613
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32002, "message": str(error)}}
614
+ elif method == "prompts/list":
615
+ result = {"prompts": PROMPTS if RECORRIDO.is_file() else []}
616
+ elif method == "prompts/get":
617
+ if params.get("name") != "recorrido" or not RECORRIDO.is_file():
618
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": f"prompt desconocido {params.get('name')}"}}
619
+ try:
620
+ result = prompt_recorrido(params.get("arguments") or {})
621
+ except ValueError as error:
622
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": str(error)}}
623
+ elif method == "tools/list":
624
+ result = {"tools": [{"name": n, "title": TITULOS[n], "description": DESCRIPCIONES[n], "inputSchema": s,
625
+ **({"outputSchema": SALIDA} if n.startswith("correr") else {}),
626
+ "annotations": anotaciones(n),
627
+ # sin-confirmar quita la confirmación por llamada (corridas desatendidas como `claude -p`)
628
+ "_meta": ({} if os.environ.get("WEBSCOPE_ESCRITURA") == "sin-confirmar" else {"anthropic/requiresUserInteraction": True})
629
+ if n == "correr_escritura" else {"anthropic/alwaysLoad": True}}
630
+ for n, f, s in herramientas(cfg)]}
631
+ elif method == "tools/call":
632
+ name, arguments = params.get("name"), params.get("arguments") or {}
633
+ if name not in {n for n, _, _ in herramientas(cfg)}:
634
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32602, "message": f"herramienta desconocida {name}"}}
635
+ ficha = (params.get("_meta") or {}).get("progressToken")
636
+ if ficha is not None:
637
+ cfg = {**cfg, "_avisar": lambda n, texto: escribir({"jsonrpc": "2.0", "method": "notifications/progress",
638
+ "params": {"progressToken": ficha, "progress": n, "message": texto}})}
639
+ try:
640
+ validar(TOOLS[name][1], arguments)
641
+ salida = TOOLS[name][0](cfg, **arguments)
642
+ result = {"content": [{"type": "text", "text": con_tope(cfg, explica(name, arguments, salida))}]}
643
+ if name.startswith("correr"):
644
+ result["structuredContent"] = estructura(con_tope(cfg, salida), explica(name, arguments, "").strip())
645
+ except Exception as error: # una herramienta fallida se informa al cliente, no tumba el servidor
646
+ aviso = f"No se pudo: {str(error).splitlines()[0][:80] if str(error) else type(error).__name__}. Sigue: corrige el argumento o usa otra herramienta."
647
+ result = {"content": [{"type": "text", "text": f"{aviso}\n{type(error).__name__}: {error}"}], "isError": True}
648
+ else:
649
+ return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"método desconocido {method}"}}
650
+ return {"jsonrpc": "2.0", "id": mid, "result": result}
651
+
652
+
653
+ def escribir(mensaje):
654
+ """Escribe un mensaje JSON-RPC en la salida estándar, una línea por mensaje."""
655
+ sys.stdout.write(json.dumps(mensaje) + "\n")
656
+ sys.stdout.flush()
657
+
658
+
659
+ def responder(cfg, linea):
660
+ """Respuesta JSON-RPC a una línea de la entrada, o None si es una notificación; un fallo inesperado vuelve como error, no tumba el servidor."""
661
+ try:
662
+ message = json.loads(linea)
663
+ except json.JSONDecodeError:
664
+ return {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "error de sintaxis"}}
665
+ if not isinstance(message, dict):
666
+ return {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "la petición debe ser un objeto JSON"}}
667
+ try:
668
+ return handle(cfg, message)
669
+ except Exception as error:
670
+ return {"jsonrpc": "2.0", "id": message.get("id"), "error": {"code": -32603, "message": f"error interno: {error}"}}
671
+
672
+
673
+ def main(argv=None):
674
+ """Carga el manifiesto de `--manifest`, WEBSCOPE_MANIFEST o ./webscope.json (sin ninguno, la carpeta actual sin scripts) y atiende JSON-RPC por la entrada estándar."""
675
+ argv = sys.argv[1:] if argv is None else argv
676
+ if "--manifest" in argv and argv.index("--manifest") + 1 < len(argv):
677
+ manifiesto = argv[argv.index("--manifest") + 1]
678
+ else:
679
+ manifiesto = os.environ.get("WEBSCOPE_MANIFEST") or ("webscope.json" if Path("webscope.json").is_file() else None)
680
+ if not manifiesto:
681
+ print(f"webscope: sin manifiesto; raíz = {Path.cwd()}, sin scripts (usa --manifest <ruta/webscope.json>)", file=sys.stderr)
682
+ try:
683
+ cfg = cargar(manifiesto)
684
+ except (OSError, ValueError) as error:
685
+ sys.exit(f"webscope: no se pudo leer el manifiesto {manifiesto}: {error}")
686
+ for line in sys.stdin:
687
+ if not line.strip():
688
+ continue
689
+ reply = responder(cfg, line)
690
+ if reply is not None:
691
+ escribir(reply)
692
+
693
+
694
+ if __name__ == "__main__":
695
+ main(sys.argv[1:])