stackhelx 1.0.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.
- stackhelx/__init__.py +2 -0
- stackhelx/__main__.py +4 -0
- stackhelx/browse.py +160 -0
- stackhelx/cli.py +1165 -0
- stackhelx/config.py +448 -0
- stackhelx/detect.py +1053 -0
- stackhelx/docker.py +202 -0
- stackhelx/doctor.py +356 -0
- stackhelx/guardrails.py +42 -0
- stackhelx/history.py +86 -0
- stackhelx/mcp.py +467 -0
- stackhelx/ports.py +392 -0
- stackhelx/registry.py +356 -0
- stackhelx/runner.py +877 -0
- stackhelx/scripts.py +123 -0
- stackhelx/server.py +1786 -0
- stackhelx/tunnel.py +207 -0
- stackhelx/web/app.css +1729 -0
- stackhelx/web/app.js +2425 -0
- stackhelx/web/index.html +378 -0
- stackhelx/web/tokens.css +104 -0
- stackhelx-1.0.0.dist-info/METADATA +323 -0
- stackhelx-1.0.0.dist-info/RECORD +26 -0
- stackhelx-1.0.0.dist-info/WHEEL +4 -0
- stackhelx-1.0.0.dist-info/entry_points.txt +3 -0
- stackhelx-1.0.0.dist-info/licenses/LICENSE +21 -0
stackhelx/mcp.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Servidor MCP (Model Context Protocol) sobre stdio para agentes de IA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import datetime
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import (
|
|
17
|
+
__version__,
|
|
18
|
+
config,
|
|
19
|
+
detect,
|
|
20
|
+
docker,
|
|
21
|
+
doctor,
|
|
22
|
+
history,
|
|
23
|
+
ports,
|
|
24
|
+
registry,
|
|
25
|
+
scripts,
|
|
26
|
+
tunnel,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Los tuneles que abrio esta sesion de MCP. `serve_stdio` los cierra al salir:
|
|
30
|
+
# un tunel es lo unico que este servidor deja fuera de su propio proceso, y del
|
|
31
|
+
# otro lado no hay nadie mirando la pantalla.
|
|
32
|
+
_tuneles: list[tunnel.Tunnel] = []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cerrar_tuneles() -> None:
|
|
36
|
+
"""Cierra lo que quedo abierto. Un fallo no puede tapar a los demas."""
|
|
37
|
+
while _tuneles:
|
|
38
|
+
try:
|
|
39
|
+
_tuneles.pop().stop()
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def handle_request(req: dict[str, Any]) -> dict[str, Any] | None:
|
|
45
|
+
req_id = req.get("id")
|
|
46
|
+
method = req.get("method")
|
|
47
|
+
params = req.get("params", {})
|
|
48
|
+
|
|
49
|
+
# JSON-RPC 2.0: una notificacion es una peticion SIN `id`, y a una
|
|
50
|
+
# notificacion no se contesta nunca. Reconocer solo
|
|
51
|
+
# `notifications/initialized` por nombre dejaba que `notifications/cancelled`
|
|
52
|
+
# y `notifications/progress` cayeran al final y se llevaran una respuesta de
|
|
53
|
+
# error con `id: null`, que es justo lo que el protocolo prohibe.
|
|
54
|
+
if "id" not in req:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
if method == "initialize":
|
|
58
|
+
return {
|
|
59
|
+
"jsonrpc": "2.0",
|
|
60
|
+
"id": req_id,
|
|
61
|
+
"result": {
|
|
62
|
+
"protocolVersion": "2024-11-05",
|
|
63
|
+
"capabilities": {"tools": {}},
|
|
64
|
+
"serverInfo": {"name": "stackhelx", "version": __version__},
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if method == "notifications/initialized":
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
if method == "tools/list":
|
|
72
|
+
return {
|
|
73
|
+
"jsonrpc": "2.0",
|
|
74
|
+
"id": req_id,
|
|
75
|
+
"result": {
|
|
76
|
+
"tools": [
|
|
77
|
+
{
|
|
78
|
+
"name": "stackhelx_status",
|
|
79
|
+
"description": "Obtiene el estado de los servicios, proyectos y puertos de StackHelx.",
|
|
80
|
+
"inputSchema": {
|
|
81
|
+
"type": "object",
|
|
82
|
+
"properties": {
|
|
83
|
+
"path": {"type": "string", "description": "Ruta opcional al proyecto"}
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"name": "stackhelx_doctor",
|
|
89
|
+
"description": "Ejecuta un diagnóstico completo del entorno de desarrollo.",
|
|
90
|
+
"inputSchema": {
|
|
91
|
+
"type": "object",
|
|
92
|
+
"properties": {
|
|
93
|
+
"path": {"type": "string", "description": "Ruta opcional al proyecto"}
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"name": "stackhelx_ports",
|
|
99
|
+
"description": "Escanea el estado de los puertos especificados o del stack actual.",
|
|
100
|
+
"inputSchema": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"ports": {
|
|
104
|
+
"type": "array",
|
|
105
|
+
"items": {"type": "integer"},
|
|
106
|
+
"description": "Lista de puertos a inspeccionar",
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"name": "stackhelx_free_port",
|
|
113
|
+
"description": "Cierra el proceso que ocupa un puerto específico.",
|
|
114
|
+
"inputSchema": {
|
|
115
|
+
"type": "object",
|
|
116
|
+
"properties": {
|
|
117
|
+
"port": {"type": "integer", "description": "Número de puerto a liberar"}
|
|
118
|
+
},
|
|
119
|
+
"required": ["port"],
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"name": "stackhelx_share",
|
|
124
|
+
"description": "Inicia un túnel público seguro hacia un puerto local.",
|
|
125
|
+
"inputSchema": {
|
|
126
|
+
"type": "object",
|
|
127
|
+
"properties": {
|
|
128
|
+
"port": {"type": "integer", "description": "Número de puerto a compartir"},
|
|
129
|
+
"provider": {
|
|
130
|
+
"type": "string",
|
|
131
|
+
"description": "Proveedor opcional: cloudflared, ngrok, lt, tailscale",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
"required": ["port"],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"name": "stackhelx_run",
|
|
139
|
+
"description": "Ejecuta un script o pipeline de tareas definido en el stack.yaml del proyecto.",
|
|
140
|
+
"inputSchema": {
|
|
141
|
+
"type": "object",
|
|
142
|
+
"properties": {
|
|
143
|
+
"script": {"type": "string", "description": "Nombre del script a ejecutar"},
|
|
144
|
+
"args": {
|
|
145
|
+
"type": "array",
|
|
146
|
+
"items": {"type": "string"},
|
|
147
|
+
"description": "Argumentos extra opcionales",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
"required": ["script"],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"name": "stackhelx_clean",
|
|
155
|
+
"description": (
|
|
156
|
+
"Limpia recursos huérfanos de Docker: contenedores parados, redes, "
|
|
157
|
+
"imágenes sin tag y caché de build. No borra volúmenes: eso tiene "
|
|
158
|
+
"datos adentro y lo hace el usuario con `stackhelx clean --volumes`."
|
|
159
|
+
),
|
|
160
|
+
# Sin `volumes`: lo que no se puede deshacer no se le ofrece a un
|
|
161
|
+
# agente. El chequeo de verdad esta en _execute_tool, porque el
|
|
162
|
+
# esquema es una sugerencia y el campo puede llegar igual.
|
|
163
|
+
"inputSchema": {"type": "object", "properties": {}},
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"name": "stackhelx_history",
|
|
167
|
+
"description": "Obtiene el historial de arranques y telemetría de un proyecto.",
|
|
168
|
+
"inputSchema": {
|
|
169
|
+
"type": "object",
|
|
170
|
+
"properties": {
|
|
171
|
+
"path": {"type": "string", "description": "Ruta opcional al proyecto"},
|
|
172
|
+
"limit": {"type": "integer", "description": "Cantidad máxima de entradas (default: 5)"},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
"name": "stackhelx_init",
|
|
178
|
+
"description": "Genera o congela la configuración de stack.yaml detectada para el proyecto.",
|
|
179
|
+
"inputSchema": {
|
|
180
|
+
"type": "object",
|
|
181
|
+
"properties": {
|
|
182
|
+
"path": {"type": "string", "description": "Ruta opcional al proyecto"},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
]
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if method == "tools/call":
|
|
191
|
+
tool_name = params.get("name") or ""
|
|
192
|
+
arguments = params.get("arguments", {})
|
|
193
|
+
t0 = time.perf_counter()
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
res_content = _execute_tool(tool_name, arguments)
|
|
197
|
+
dur_ms = round((time.perf_counter() - t0) * 1000, 2)
|
|
198
|
+
record_tool_call(tool_name, dur_ms, "ok", f"ok ({len(res_content)} bytes)")
|
|
199
|
+
return {
|
|
200
|
+
"jsonrpc": "2.0",
|
|
201
|
+
"id": req_id,
|
|
202
|
+
"result": {"content": [{"type": "text", "text": res_content}]},
|
|
203
|
+
}
|
|
204
|
+
except Exception as exc:
|
|
205
|
+
dur_ms = round((time.perf_counter() - t0) * 1000, 2)
|
|
206
|
+
msg = str(exc)
|
|
207
|
+
status = "rate_limited" if "Límite de acciones MCP excedido" in msg else "error"
|
|
208
|
+
record_tool_call(tool_name, dur_ms, status, msg)
|
|
209
|
+
return {
|
|
210
|
+
"jsonrpc": "2.0",
|
|
211
|
+
"id": req_id,
|
|
212
|
+
"result": {
|
|
213
|
+
"content": [{"type": "text", "text": f"Error ejecutando {tool_name}: {exc}"}],
|
|
214
|
+
"isError": True,
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"jsonrpc": "2.0",
|
|
220
|
+
"id": req_id,
|
|
221
|
+
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
_ACTION_BUDGET_WINDOW = 60.0
|
|
226
|
+
_MAX_ACTIONS_PER_WINDOW = 30
|
|
227
|
+
_action_timestamps: collections.deque[float] = collections.deque()
|
|
228
|
+
_action_lock = threading.Lock()
|
|
229
|
+
|
|
230
|
+
_MAX_TELEMETRY_EVENTS = 100
|
|
231
|
+
_telemetry_events: collections.deque[dict[str, Any]] = collections.deque(maxlen=_MAX_TELEMETRY_EVENTS)
|
|
232
|
+
_telemetry_lock = threading.Lock()
|
|
233
|
+
_telemetry_counter: int = 0
|
|
234
|
+
_telemetry_by_tool: dict[str, int] = collections.defaultdict(int)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def record_tool_call(tool: str, duration_ms: float, status: str, summary: str = "") -> None:
|
|
238
|
+
"""Registra una invocación de herramienta MCP en el buffer circular de telemetría."""
|
|
239
|
+
global _telemetry_counter
|
|
240
|
+
now_iso = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
241
|
+
with _telemetry_lock:
|
|
242
|
+
_telemetry_counter += 1
|
|
243
|
+
_telemetry_by_tool[tool] += 1
|
|
244
|
+
_telemetry_events.appendleft(
|
|
245
|
+
{
|
|
246
|
+
"tool": tool,
|
|
247
|
+
"timestamp": now_iso,
|
|
248
|
+
"duration_ms": duration_ms,
|
|
249
|
+
"status": status,
|
|
250
|
+
"summary": summary[:200] if summary else "",
|
|
251
|
+
}
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def get_telemetry() -> dict[str, Any]:
|
|
256
|
+
"""Devuelve las estadísticas y llamadas recientes a herramientas MCP."""
|
|
257
|
+
now = time.monotonic()
|
|
258
|
+
with _action_lock, _telemetry_lock:
|
|
259
|
+
while _action_timestamps and _action_timestamps[0] < now - _ACTION_BUDGET_WINDOW:
|
|
260
|
+
_action_timestamps.popleft()
|
|
261
|
+
active_rate = len(_action_timestamps)
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
"total_calls": _telemetry_counter,
|
|
265
|
+
"active_rate_per_min": active_rate,
|
|
266
|
+
"rate_limit_max": _MAX_ACTIONS_PER_WINDOW,
|
|
267
|
+
"by_tool": dict(_telemetry_by_tool),
|
|
268
|
+
"recent_events": list(_telemetry_events),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def clear_telemetry() -> None:
|
|
273
|
+
"""Limpia el buffer y contadores de telemetría (usado para tests)."""
|
|
274
|
+
global _telemetry_counter
|
|
275
|
+
with _action_lock:
|
|
276
|
+
_action_timestamps.clear()
|
|
277
|
+
with _telemetry_lock:
|
|
278
|
+
_telemetry_counter = 0
|
|
279
|
+
_telemetry_by_tool.clear()
|
|
280
|
+
_telemetry_events.clear()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _check_action_budget() -> None:
|
|
284
|
+
now = time.monotonic()
|
|
285
|
+
with _action_lock:
|
|
286
|
+
while _action_timestamps and _action_timestamps[0] < now - _ACTION_BUDGET_WINDOW:
|
|
287
|
+
_action_timestamps.popleft()
|
|
288
|
+
if len(_action_timestamps) >= _MAX_ACTIONS_PER_WINDOW:
|
|
289
|
+
raise RuntimeError(
|
|
290
|
+
f"Límite de acciones MCP excedido ({_MAX_ACTIONS_PER_WINDOW} llamadas/min). Espera unos momentos."
|
|
291
|
+
)
|
|
292
|
+
_action_timestamps.append(now)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _execute_tool(name: str, args: dict[str, Any]) -> str:
|
|
296
|
+
_check_action_budget()
|
|
297
|
+
cwd = Path(args.get("path") or Path.cwd())
|
|
298
|
+
|
|
299
|
+
if name in ("stackhelx_status", "portmaster_status"):
|
|
300
|
+
stack = detect.stack_for(cwd)
|
|
301
|
+
registered_paths = registry.paths()
|
|
302
|
+
data = {
|
|
303
|
+
"project_name": stack.name,
|
|
304
|
+
"project_root": str(stack.root),
|
|
305
|
+
"services": [
|
|
306
|
+
{
|
|
307
|
+
"name": s.name,
|
|
308
|
+
"command": s.command,
|
|
309
|
+
"port": s.port,
|
|
310
|
+
"ready": s.ready,
|
|
311
|
+
"needs": list(s.needs),
|
|
312
|
+
}
|
|
313
|
+
for s in stack.services.values()
|
|
314
|
+
],
|
|
315
|
+
"scripts": list(stack.scripts.keys()),
|
|
316
|
+
"registered_projects": [str(p) for p in registered_paths],
|
|
317
|
+
}
|
|
318
|
+
return json.dumps(data, indent=2)
|
|
319
|
+
|
|
320
|
+
if name in ("stackhelx_doctor", "portmaster_doctor"):
|
|
321
|
+
results = doctor.run(cwd)
|
|
322
|
+
lines = [
|
|
323
|
+
f"[{r.level.upper()}] {r.name}: {r.detail or 'ok'}"
|
|
324
|
+
+ (f" -> Solución: {r.fix}" if r.fix else "")
|
|
325
|
+
for r in results
|
|
326
|
+
]
|
|
327
|
+
return "\n".join(lines)
|
|
328
|
+
|
|
329
|
+
if name in ("stackhelx_ports", "portmaster_ports"):
|
|
330
|
+
port_list = args.get("ports")
|
|
331
|
+
if not port_list:
|
|
332
|
+
stack = detect.stack_for(cwd)
|
|
333
|
+
port_list = stack.ports()
|
|
334
|
+
statuses = [ports.scan(p) for p in port_list]
|
|
335
|
+
data = [
|
|
336
|
+
{
|
|
337
|
+
"port": s.port,
|
|
338
|
+
"free": s.free,
|
|
339
|
+
"pid": s.pid,
|
|
340
|
+
"process_name": s.name,
|
|
341
|
+
"command": s.cmdline,
|
|
342
|
+
}
|
|
343
|
+
for s in statuses
|
|
344
|
+
]
|
|
345
|
+
return json.dumps(data, indent=2)
|
|
346
|
+
|
|
347
|
+
if name in ("stackhelx_free_port", "portmaster_free_port"):
|
|
348
|
+
port = ports.check_port(int(args["port"]))
|
|
349
|
+
status = ports.scan(port)
|
|
350
|
+
if status.free:
|
|
351
|
+
return f"El puerto {port} ya esta libre."
|
|
352
|
+
if status.pid is None:
|
|
353
|
+
return f"El puerto {port} esta ocupado por un proceso que no es visible con estos permisos."
|
|
354
|
+
# El pid y el create_time que vio el escaneo. Aca iba `kill(port)`, o sea
|
|
355
|
+
# el numero de puerto en el lugar del pid: liberar el 3000 mataba al
|
|
356
|
+
# proceso 3000, que no tiene nada que ver. El create_time es la
|
|
357
|
+
# verificacion contra reciclado de PIDs y no es opcional en este proyecto.
|
|
358
|
+
#
|
|
359
|
+
# Sin valor de retorno que mirar: `kill` no devuelve nada y levanta si el
|
|
360
|
+
# proceso esta protegido, ya no existe o faltan permisos. Esas excepciones
|
|
361
|
+
# las convierte `tools/call` en isError, que es como el agente se entera.
|
|
362
|
+
ports.kill(status.pid, status.create_time, port=port)
|
|
363
|
+
return f"Proceso en puerto {port} (pid {status.pid}) liberado."
|
|
364
|
+
|
|
365
|
+
if name in ("stackhelx_share", "portmaster_share"):
|
|
366
|
+
port = ports.check_port(int(args["port"]))
|
|
367
|
+
if port == doctor.UI_PORT:
|
|
368
|
+
raise ValueError(f"No se permite compartir el puerto de gestión de StackHelx ({port}).")
|
|
369
|
+
try:
|
|
370
|
+
stack = detect.stack_for(cwd)
|
|
371
|
+
allowed = set(stack.ports())
|
|
372
|
+
if not allowed or port not in allowed:
|
|
373
|
+
raise ValueError(
|
|
374
|
+
f"El puerto {port} no pertenece a los puertos declarados de {stack.name} ({sorted(allowed)}). "
|
|
375
|
+
"Por seguridad, solo se pueden compartir puertos válidos del proyecto."
|
|
376
|
+
)
|
|
377
|
+
except config.ConfigError as exc:
|
|
378
|
+
raise ValueError(f"No se puede compartir el puerto sin un stack.yaml válido: {exc}")
|
|
379
|
+
provider = args.get("provider")
|
|
380
|
+
tun = tunnel.start_tunnel(port, provider=provider)
|
|
381
|
+
_tuneles.append(tun)
|
|
382
|
+
return f"Tunel activo via {tun.provider}: {tun.url}"
|
|
383
|
+
|
|
384
|
+
if name in ("stackhelx_run", "portmaster_run"):
|
|
385
|
+
script_name = args["script"]
|
|
386
|
+
extra = args.get("args") or []
|
|
387
|
+
stack = detect.stack_for(cwd)
|
|
388
|
+
code = scripts.run_script(stack, script_name, extra_args=extra)
|
|
389
|
+
return f"Script '{script_name}' finalizado con código de salida {code}."
|
|
390
|
+
|
|
391
|
+
if name in ("stackhelx_clean", "portmaster_clean"):
|
|
392
|
+
# Los volumenes no, y no por el esquema sino aca: un agente puede mandar
|
|
393
|
+
# el campo igual. El resto del prune (cache y capas sin tag) se regenera
|
|
394
|
+
# solo; un volumen tiene la base de datos del proyecto adentro y no
|
|
395
|
+
# vuelve. El CLI y la interfaz preguntan antes; el MCP no tiene donde
|
|
396
|
+
# preguntar, asi que lo que no se puede deshacer no se ofrece.
|
|
397
|
+
if bool(args.get("volumes", False)):
|
|
398
|
+
raise ValueError(
|
|
399
|
+
"borrar volumenes de Docker no se hace desde un agente: tienen datos "
|
|
400
|
+
"adentro y no se puede deshacer. Corre `stackhelx clean --volumes` "
|
|
401
|
+
"vos mismo, que pregunta antes."
|
|
402
|
+
)
|
|
403
|
+
ok, msg = docker.prune(docker.DEFAULT_TARGETS)
|
|
404
|
+
return f"Docker prune: {'éxito' if ok else 'fallo'} - {msg}"
|
|
405
|
+
|
|
406
|
+
if name in ("stackhelx_history", "portmaster_history"):
|
|
407
|
+
pid = registry.project_id(cwd)
|
|
408
|
+
limit = int(args.get("limit", 5))
|
|
409
|
+
entries = history.read(pid, limit=limit)
|
|
410
|
+
return json.dumps({"project_id": pid, "entries": entries}, indent=2)
|
|
411
|
+
|
|
412
|
+
if name in ("stackhelx_init", "portmaster_init"):
|
|
413
|
+
target = detect.freeze(cwd)
|
|
414
|
+
return f"Stack congelado exitosamente en: {target}"
|
|
415
|
+
|
|
416
|
+
raise ValueError(f"Herramienta desconocida: {name}")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _reservar_stdout():
|
|
420
|
+
"""Deja el descriptor 1 solo para el protocolo y manda lo demas a stderr.
|
|
421
|
+
|
|
422
|
+
Sobre stdio el JSON-RPC comparte el descriptor 1 con todo lo que imprima el
|
|
423
|
+
proceso. `portmaster_run` lanza los comandos del usuario heredando ese
|
|
424
|
+
descriptor, asi que un `echo` adentro de un script se metia entre dos
|
|
425
|
+
respuestas y el cliente perdia la sesion.
|
|
426
|
+
|
|
427
|
+
Se duplica el descriptor antes de reapuntarlo: el protocolo escribe por la
|
|
428
|
+
copia, y lo que siga escribiendo en 1 termina en stderr, que los clientes MCP
|
|
429
|
+
leen como log. Tiene que ser a nivel de descriptor y no cambiando
|
|
430
|
+
`sys.stdout`: un subproceso hereda el descriptor y no el objeto de Python.
|
|
431
|
+
"""
|
|
432
|
+
try:
|
|
433
|
+
copia = os.dup(sys.stdout.fileno())
|
|
434
|
+
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
|
|
435
|
+
except (OSError, ValueError, io.UnsupportedOperation):
|
|
436
|
+
# Sin descriptores de verdad (un arnes que captura la salida): queda el
|
|
437
|
+
# stdout de Python, que al menos ordena lo que imprima este proceso.
|
|
438
|
+
return sys.stdout
|
|
439
|
+
return os.fdopen(copia, "w", encoding="utf-8", buffering=1)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def serve_stdio() -> None:
|
|
443
|
+
"""Bucle principal de servidor MCP sobre stdio.
|
|
444
|
+
|
|
445
|
+
Al terminar cierra los tuneles que abrio, por el mismo motivo que
|
|
446
|
+
`server._ciclo_de_vida`: sin esto el agente cerraba la sesion y el cliente
|
|
447
|
+
de tuneles seguia vivo, con el puerto expuesto a internet, sin nada en
|
|
448
|
+
pantalla que lo dijera. Aca es peor que en la interfaz, porque del otro lado
|
|
449
|
+
no hay nadie mirando.
|
|
450
|
+
"""
|
|
451
|
+
protocolo = _reservar_stdout()
|
|
452
|
+
try:
|
|
453
|
+
for line in sys.stdin:
|
|
454
|
+
line = line.strip()
|
|
455
|
+
if not line:
|
|
456
|
+
continue
|
|
457
|
+
try:
|
|
458
|
+
req = json.loads(line)
|
|
459
|
+
except json.JSONDecodeError:
|
|
460
|
+
continue
|
|
461
|
+
|
|
462
|
+
resp = handle_request(req)
|
|
463
|
+
if resp is not None:
|
|
464
|
+
protocolo.write(json.dumps(resp) + "\n")
|
|
465
|
+
protocolo.flush()
|
|
466
|
+
finally:
|
|
467
|
+
cerrar_tuneles()
|