yunta-harness 1.0.2__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.
yunta/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ from .agent import Agent
2
+ from .api import Block, BlockType, Message, Response, Role, StopReason, ToolDef, Usage
3
+ from .compact import NoCompaction, SlidingWindow
4
+ from .provider import LiteLLMProvider
5
+ from .feedback import FeedbackStore
6
+
7
+ __all__ = [
8
+ # Núcleo (estable desde v0.1)
9
+ "Agent",
10
+ "Block",
11
+ "BlockType",
12
+ "Message",
13
+ "Response",
14
+ "Role",
15
+ "StopReason",
16
+ "ToolDef",
17
+ "Usage",
18
+ # Compactación (estable desde v0.1)
19
+ "NoCompaction",
20
+ "SlidingWindow",
21
+ # Provider (estable desde v0.1; send/on_text desde v0.7)
22
+ "LiteLLMProvider",
23
+ # Auto-feedback (estable desde v0.3)
24
+ "FeedbackStore",
25
+ ]
yunta/agent.py ADDED
@@ -0,0 +1,181 @@
1
+ import difflib
2
+ import inspect
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .api import Block, BlockType, Message, Response, Role, StopReason, Usage
7
+ from .tools import registry
8
+
9
+
10
+ class Agent:
11
+ def __init__(
12
+ self,
13
+ provider,
14
+ system: str,
15
+ compactor=None,
16
+ max_turns: int = 30,
17
+ confirm=None,
18
+ tools: list | None = None,
19
+ ):
20
+ self.provider = provider
21
+ self.system = system
22
+ self.compactor = compactor
23
+ self.max_turns = max_turns
24
+ self.confirm = confirm
25
+ self.messages: list[Message] = []
26
+ self._tools_subset = list(tools) if tools is not None else None
27
+ self.usage = Usage()
28
+
29
+ def send(self, prompt: str) -> str:
30
+ self.usage.turns += 1
31
+ self.messages.append(
32
+ Message(role=Role.USER, content=[Block(type=BlockType.TEXT, text=prompt)])
33
+ )
34
+ return self._loop()
35
+
36
+ def _definitions(self) -> list:
37
+ if self._tools_subset is None:
38
+ return registry.definitions()
39
+ return [
40
+ t for t in registry.definitions() if t.name in {tool.name for tool in self._tools_subset}
41
+ ]
42
+
43
+ def _loop(self) -> str:
44
+ final_text = []
45
+ try:
46
+ for _ in range(self.max_turns):
47
+ if self.compactor:
48
+ self.messages = self.compactor.compact(self.messages)
49
+
50
+ try:
51
+ supports_stream = (
52
+ "on_text" in inspect.signature(self.provider.send).parameters
53
+ )
54
+ except (TypeError, ValueError):
55
+ supports_stream = False
56
+ self._streamed = False
57
+ if supports_stream:
58
+ resp = self.provider.send(self.messages, self._definitions(), on_text=self._stream_text)
59
+ else:
60
+ resp = self.provider.send(self.messages, self._definitions())
61
+ if not hasattr(self.provider, "total_usage") and getattr(resp, "usage", None):
62
+ self.usage = self.usage.add(resp.usage)
63
+ self.messages.append(Message(role=Role.ASSISTANT, content=resp.content))
64
+
65
+ tool_results = []
66
+ has_tool_call = False
67
+ for b in resp.content:
68
+ if b.type == BlockType.TEXT and b.text:
69
+ if self._streamed:
70
+ print()
71
+ else:
72
+ print(b.text)
73
+ final_text.append(b.text)
74
+ elif b.type == BlockType.TOOL_USE:
75
+ has_tool_call = True
76
+ result, is_err = self._execute_tool(b.tool_name, b.tool_input)
77
+ tool_results.append(
78
+ Block(
79
+ type=BlockType.TOOL_RESULT,
80
+ tool_use_id=b.tool_use_id,
81
+ tool_result=result,
82
+ is_error=is_err,
83
+ )
84
+ )
85
+
86
+ if resp.stop_reason != StopReason.TOOL_USE or not has_tool_call:
87
+ return "\n".join(final_text).strip()
88
+
89
+ self.messages.append(Message(role=Role.USER, content=tool_results))
90
+ except KeyboardInterrupt:
91
+ print("\n(interrumpido por el usuario)")
92
+ if self.messages and self.messages[-1].role == Role.USER:
93
+ self.messages.append(
94
+ Message(
95
+ role=Role.ASSISTANT,
96
+ content=[Block(type=BlockType.TEXT, text="[interrumpido por el usuario]")],
97
+ )
98
+ )
99
+ return "\n".join(final_text).strip() or "[interrumpido por el usuario]"
100
+
101
+ return "\n".join(final_text).strip()
102
+
103
+ def _stream_text(self, delta: str) -> None:
104
+ print(delta, end="", flush=True)
105
+ self._streamed = True
106
+
107
+ def _execute_tool(self, name: str, raw_input: str) -> tuple[str, bool]:
108
+ self.usage.tool_counts[name] = self.usage.tool_counts.get(name, 0) + 1
109
+ tool = registry.get(name)
110
+ if tool is None:
111
+ self.usage.tool_errors += 1
112
+ return f"unknown tool: {name}", True
113
+
114
+ detail = self._tool_detail(name, raw_input)
115
+ print(f"[tool] {name} {raw_input}")
116
+ if tool.requires_approval and not self._approve(name, detail):
117
+ self.usage.tool_errors += 1
118
+ return "user denied this tool call", True
119
+
120
+ try:
121
+ res = tool.fn(raw_input)
122
+ return res, False
123
+ except Exception as e:
124
+ self.usage.tool_errors += 1
125
+ return f"{type(e).__name__}: {e}", True
126
+
127
+ @staticmethod
128
+ def _tool_detail(name: str, raw_input: str) -> str:
129
+ if name not in ("write_file", "str_replace"):
130
+ return ""
131
+ try:
132
+ args = json.loads(raw_input or "{}")
133
+ except json.JSONDecodeError:
134
+ return ""
135
+
136
+ path = args.get("path", "")
137
+ if not path:
138
+ return ""
139
+
140
+ p = Path(path)
141
+ old = p.read_text(encoding="utf-8", errors="replace") if p.exists() else ""
142
+
143
+ if name == "write_file":
144
+ new_content = args.get("content", "")
145
+ elif name == "str_replace":
146
+ old_str = args.get("old_str", "")
147
+ new_str = args.get("new_str", "")
148
+ if not old_str or old.count(old_str) != 1:
149
+ return ""
150
+ new_content = old.replace(old_str, new_str, 1)
151
+
152
+ diff = difflib.unified_diff(
153
+ old.splitlines(keepends=True),
154
+ new_content.splitlines(keepends=True),
155
+ fromfile=f"a/{path}",
156
+ tofile=f"b/{path}",
157
+ )
158
+ return "".join(diff)
159
+
160
+ def _approve(self, name: str, detail: str) -> bool:
161
+ if self.confirm is not None:
162
+ return self.confirm(name, detail)
163
+ if detail:
164
+ print(detail)
165
+ answer = input(f"¿Ejecutar {name}? [y/N] ").strip().lower()
166
+ return answer == "y"
167
+
168
+ @property
169
+ def total_usage(self) -> Usage:
170
+ p_usage = getattr(self.provider, "total_usage", None)
171
+ in_tok = p_usage.input_tokens if p_usage else self.usage.input_tokens
172
+ out_tok = p_usage.output_tokens if p_usage else self.usage.output_tokens
173
+ cached_tok = p_usage.cached_tokens if p_usage else self.usage.cached_tokens
174
+ return Usage(
175
+ input_tokens=in_tok,
176
+ output_tokens=out_tok,
177
+ cached_tokens=cached_tok,
178
+ tool_counts=dict(self.usage.tool_counts),
179
+ tool_errors=self.usage.tool_errors,
180
+ turns=self.usage.turns,
181
+ )
yunta/api.py ADDED
@@ -0,0 +1,123 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+
4
+
5
+ class Role(str, Enum):
6
+ USER = "user"
7
+ ASSISTANT = "assistant"
8
+
9
+
10
+ class BlockType(str, Enum):
11
+ TEXT = "text"
12
+ TOOL_USE = "tool_use"
13
+ TOOL_RESULT = "tool_result"
14
+
15
+
16
+ class StopReason(str, Enum):
17
+ END_TURN = "end_turn"
18
+ TOOL_USE = "tool_use"
19
+ OTHER = "other"
20
+
21
+
22
+ @dataclass
23
+ class Block:
24
+ type: BlockType
25
+ text: str = ""
26
+ tool_use_id: str = ""
27
+ tool_name: str = ""
28
+ tool_input: str = ""
29
+ tool_result: str = ""
30
+ is_error: bool = False
31
+
32
+
33
+ @dataclass
34
+ class Message:
35
+ role: Role
36
+ content: list[Block] = field(default_factory=list)
37
+
38
+ def has_tool_result(self) -> bool:
39
+ return any(b.type == BlockType.TOOL_RESULT for b in self.content)
40
+
41
+
42
+ @dataclass
43
+ class ToolDef:
44
+ name: str
45
+ description: str
46
+ parameters: dict
47
+
48
+
49
+ @dataclass
50
+ class Usage:
51
+ input_tokens: int = 0
52
+ output_tokens: int = 0
53
+ cached_tokens: int = 0
54
+ tool_counts: dict[str, int] = field(default_factory=dict)
55
+ tool_errors: int = 0
56
+ turns: int = 0
57
+
58
+ def add(self, other: "Usage") -> "Usage":
59
+ merged_tools = dict(self.tool_counts)
60
+ for k, v in other.tool_counts.items():
61
+ merged_tools[k] = merged_tools.get(k, 0) + v
62
+ return Usage(
63
+ input_tokens=self.input_tokens + other.input_tokens,
64
+ output_tokens=self.output_tokens + other.output_tokens,
65
+ cached_tokens=self.cached_tokens + other.cached_tokens,
66
+ tool_counts=merged_tools,
67
+ tool_errors=self.tool_errors + other.tool_errors,
68
+ turns=self.turns + other.turns,
69
+ )
70
+
71
+ @property
72
+ def cache_rate(self) -> float:
73
+ """Porcentaje de tokens de entrada que vinieron de caché."""
74
+ total_in = self.input_tokens + self.cached_tokens
75
+ return (self.cached_tokens / total_in * 100.0) if total_in else 0.0
76
+
77
+ @property
78
+ def theoretical_raw_tokens(self) -> int:
79
+ """Tokens de entrada brutos que habrías transferido en un chat crudo sin caching."""
80
+ return self.input_tokens + self.cached_tokens
81
+
82
+ @property
83
+ def total_tool_calls(self) -> int:
84
+ """Total acumulado de llamadas a herramientas."""
85
+ return sum(self.tool_counts.values())
86
+
87
+ def format_summary(self) -> str:
88
+ """Formatea un resumen legible y comparativo en 2-3 líneas limpias."""
89
+ total_in = self.theoretical_raw_tokens
90
+ cached_info = f" (⚡ {self.cache_rate:.1f}% ahorro de caché)" if self.cached_tokens else ""
91
+ lines = [
92
+ f"Tokens: entrada={self.input_tokens:,} | cacheados={self.cached_tokens:,}{cached_info} | salida={self.output_tokens:,}",
93
+ f"Sin Harness habrías transferido: {total_in:,} tokens de entrada",
94
+ ]
95
+ if self.tool_counts:
96
+ breakdown = ", ".join(f"{k}: {v}" for k, v in sorted(self.tool_counts.items()))
97
+ err_info = f" (errores/rechazos: {self.tool_errors})" if self.tool_errors else ""
98
+ lines.append(f"Herramientas ({self.total_tool_calls} llamadas): {breakdown}{err_info}")
99
+ if self.turns:
100
+ lines.append(f"Turnos de interacción: {self.turns}")
101
+ return "\n".join(lines)
102
+
103
+
104
+ @dataclass
105
+ class Response:
106
+ content: list[Block] = field(default_factory=list)
107
+ stop_reason: StopReason = StopReason.OTHER
108
+ usage: Usage = field(default_factory=Usage)
109
+
110
+
111
+ def render_transcript(messages: list[Message]) -> str:
112
+ lines = []
113
+ for m in messages:
114
+ parts = []
115
+ for b in m.content:
116
+ if b.type == BlockType.TEXT:
117
+ parts.append(b.text)
118
+ elif b.type == BlockType.TOOL_USE:
119
+ parts.append(f"[called {b.tool_name} with {b.tool_input}]")
120
+ else:
121
+ parts.append(f"[tool result: {b.tool_result}]")
122
+ lines.append(f"{m.role.value}: " + "\n".join(parts))
123
+ return "\n".join(lines)
yunta/cli.py ADDED
@@ -0,0 +1,103 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from .agent import Agent
6
+ from .feedback import FeedbackStore
7
+ from .mcp import load_mcp_servers
8
+ from .provider import LiteLLMProvider
9
+ from .tools import bash, delegate, files, memory, search # noqa: F401 — registro vía decoradores
10
+
11
+ SYSTEM_PROMPT = """Eres un ingeniero de software que programa en pareja a través del harness Yunta.
12
+ Trabajas iterando: lees archivos, ejecutas comandos y editas código usando tus tools.
13
+ Sé conciso. Si un tool falla, el error vuelve a tu contexto: ajústalo y reintenta.
14
+ Responde en el idioma del usuario.
15
+
16
+ Frontera de rol y entorno de ejecución:
17
+ - Yunta es tu banco de herramientas en terminal (read_file, str_replace, bash), NO el runtime de la aplicación.
18
+ - Tu objetivo es desarrollar el código del proyecto del usuario en este espacio de trabajo.
19
+ - NUNCA crees servicios, daemons ni plugins que corran "dentro de Yunta". El software desarrollado vivirá en su propio entorno de producción (ej. nube, contenedor, Power Automate, web, CLI propio, etc.).
20
+
21
+ Reglas de honestidad y verificación:
22
+ - NUNCA afirmes haber ejecutado o editado algo sin haberlo hecho con una tool
23
+ real en esta conversación. Narrar acciones imaginarias es un fallo grave.
24
+ - Verifica tu trabajo: tras editar código, ejecuta los tests o el comando
25
+ que demuestre el resultado antes de declararlo resuelto."""
26
+
27
+
28
+ def load_system_prompt(feedback: FeedbackStore | None = None) -> str:
29
+ env_prompt = os.environ.get("YUNTA_SYSTEM_PROMPT")
30
+ user_prompt_file = Path.home() / ".yunta" / "system_prompt.md"
31
+ if env_prompt:
32
+ prompt = env_prompt
33
+ elif user_prompt_file.exists():
34
+ prompt = user_prompt_file.read_text(encoding="utf-8")
35
+ else:
36
+ prompt = SYSTEM_PROMPT
37
+
38
+ agents_md = Path("AGENTS.md")
39
+ if agents_md.exists():
40
+ prompt += "\n\n# Contexto del proyecto (AGENTS.md)\n\n" + agents_md.read_text(
41
+ encoding="utf-8"
42
+ )
43
+ if feedback is not None:
44
+ prompt += feedback.preamble()
45
+ return prompt
46
+
47
+
48
+ def main():
49
+ if sys.platform == "win32":
50
+ try:
51
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
52
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
53
+ except Exception:
54
+ pass
55
+ feedback = FeedbackStore()
56
+ system = load_system_prompt(feedback)
57
+ provider = LiteLLMProvider(system=system)
58
+ delegate.set_provider(provider)
59
+ mcp_clients = load_mcp_servers()
60
+ agent = Agent(provider=provider, system=system)
61
+
62
+ print(f"yunta — modelo: {provider.model()}")
63
+ print("Escribe tu consulta, /tokens o /metrics para telemetría, /clear para limpiar, /exit para salir.\n")
64
+
65
+ try:
66
+ while True:
67
+ try:
68
+ prompt = input("> ").strip()
69
+ except (EOFError, KeyboardInterrupt):
70
+ print()
71
+ break
72
+
73
+ if not prompt:
74
+ continue
75
+ if prompt == "/exit":
76
+ if agent.messages:
77
+ feedback.summarize(provider, agent.messages)
78
+ break
79
+ if prompt == "/clear":
80
+ agent.messages.clear()
81
+ print("(historial limpio)\n")
82
+ continue
83
+ if prompt in ("/tokens", "/metrics"):
84
+ u = agent.total_usage
85
+ print(u.format_summary() + "\n")
86
+ continue
87
+
88
+ try:
89
+ agent.send(prompt)
90
+ except KeyboardInterrupt:
91
+ print()
92
+ except SystemExit:
93
+ raise
94
+ except Exception as e:
95
+ print(f"error: {e}", file=sys.stderr)
96
+ print()
97
+ finally:
98
+ for c in mcp_clients:
99
+ c.close()
100
+
101
+
102
+ if __name__ == "__main__":
103
+ main()
yunta/compact.py ADDED
@@ -0,0 +1,28 @@
1
+ from .api import BlockType, Message, Role
2
+
3
+
4
+ class NoCompaction:
5
+ def compact(self, messages: list[Message]) -> list[Message]:
6
+ return messages
7
+
8
+
9
+ class SlidingWindow:
10
+ """Descarta los mensajes más viejos, cortando solo en límites seguros:
11
+ justo antes de un mensaje user que no contiene tool_results."""
12
+
13
+ def __init__(self, max_messages: int = 40):
14
+ self.max_messages = max_messages
15
+
16
+ def compact(self, messages: list[Message]) -> list[Message]:
17
+ if len(messages) <= self.max_messages:
18
+ return messages
19
+ excess = len(messages) - self.max_messages
20
+ # límite seguro: el índice del primer mensaje user (sin tool_results)
21
+ # que esté en o después del exceso; nunca deja un assistant o
22
+ # tool_result huérfano al inicio de lo que queda.
23
+ cut = excess
24
+ for i in range(excess, len(messages)):
25
+ if messages[i].role == Role.USER and not messages[i].has_tool_result():
26
+ cut = i
27
+ break
28
+ return messages[cut:]
yunta/feedback.py ADDED
@@ -0,0 +1,74 @@
1
+ from datetime import date
2
+ from pathlib import Path
3
+
4
+ from .api import Block, BlockType, Message, Role
5
+
6
+ LESSON_PROMPT = """Auto-evalúa la sesión que termina. Responde EXACTAMENTE en tres líneas:
7
+
8
+ TAREA: <la tarea principal, en una frase>
9
+ RESULTADO: <logrado / parcial / fallido, con una palabra de por qué>
10
+ LECCION: <una regla concreta y accionable para futuras sesiones; si todo
11
+ salió bien, una práctica que ayudó>
12
+
13
+ Sin más texto."""
14
+
15
+ HEADER = "# Lecciones de sesiones anteriores (auto-feedback del harness)\n\n"
16
+
17
+
18
+ class FeedbackStore:
19
+ """Registro persistente de auto-evaluaciones. Las últimas lecciones se
20
+ inyectan en el system prompt al arrancar: el agente aprende de sí mismo."""
21
+
22
+ def __init__(self, path: str = ".yunta/learnings.md"):
23
+ self.path = Path(path)
24
+
25
+ def append(self, task: str, outcome: str, lesson: str) -> None:
26
+ self.path.parent.mkdir(parents=True, exist_ok=True)
27
+ if not self.path.exists():
28
+ self.path.write_text(HEADER, encoding="utf-8")
29
+ line = f"- [{date.today().isoformat()}] {task} → {outcome}. Lección: {lesson}\n"
30
+ with self.path.open("a", encoding="utf-8") as f:
31
+ f.write(line)
32
+
33
+ def lessons(self, limit: int = 5) -> list[str]:
34
+ if not self.path.exists():
35
+ return []
36
+ lines = [
37
+ l.strip("- \n")
38
+ for l in self.path.read_text(encoding="utf-8").splitlines()
39
+ if l.startswith("- ")
40
+ ]
41
+ return lines[-limit:]
42
+
43
+ def preamble(self, limit: int = 5) -> str:
44
+ lessons = self.lessons(limit)
45
+ if not lessons:
46
+ return ""
47
+ body = "\n".join(f"- {l}" for l in lessons)
48
+ return (
49
+ "\n\n# Lecciones de sesiones anteriores (aprendidas por ti mismo)\n\n"
50
+ f"{body}\n"
51
+ )
52
+
53
+ def summarize(self, provider, messages: list[Message]) -> None:
54
+ """Pide al modelo la auto-evaluación de la sesión y la persiste.
55
+ Best-effort: cualquier fallo se ignora silenciosamente."""
56
+ try:
57
+ resp = provider.send(
58
+ [Message(role=Role.USER, content=[Block(type=BlockType.TEXT, text=LESSON_PROMPT)])]
59
+ + messages,
60
+ [],
61
+ )
62
+ text = {b.type: b.text for b in resp.content if b.type == BlockType.TEXT}
63
+ parts = {}
64
+ for line in text.get(BlockType.TEXT, "").splitlines():
65
+ if ":" in line:
66
+ k, v = line.split(":", 1)
67
+ parts[k.strip().upper()] = v.strip()
68
+ task = parts.get("TAREA", "(sin registrar)")
69
+ outcome = parts.get("RESULTADO", "desconocido")
70
+ lesson = parts.get("LECCION", "")
71
+ if lesson:
72
+ self.append(task, outcome, lesson)
73
+ except Exception:
74
+ pass
yunta/mcp.py ADDED
@@ -0,0 +1,147 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ from pathlib import Path
5
+ from .tools import Tool, _parse, registry
6
+
7
+
8
+ class MCPClient:
9
+ def __init__(self, name: str, command: str, args: list[str] | None = None, env: dict | None = None):
10
+ self.name = name
11
+ full_env = {**os.environ, **(env or {})}
12
+ cmd = [command] + (args or [])
13
+ self.proc = subprocess.Popen(
14
+ cmd,
15
+ stdin=subprocess.PIPE,
16
+ stdout=subprocess.PIPE,
17
+ stderr=subprocess.PIPE,
18
+ text=True,
19
+ encoding="utf-8",
20
+ errors="replace",
21
+ env=full_env,
22
+ )
23
+ self._req_id = 0
24
+ self._init_server()
25
+
26
+ def _next_id(self) -> int:
27
+ self._req_id += 1
28
+ return self._req_id
29
+
30
+ def _send(self, method: str, params: dict | None = None, is_notification: bool = False) -> dict | None:
31
+ req_id = None if is_notification else self._next_id()
32
+ msg = {"jsonrpc": "2.0", "method": method}
33
+ if req_id is not None:
34
+ msg["id"] = req_id
35
+ if params is not None:
36
+ msg["params"] = params
37
+ line = json.dumps(msg) + "\n"
38
+ if not self.proc.stdin:
39
+ raise RuntimeError(f"MCP server '{self.name}' stdin is closed")
40
+ self.proc.stdin.write(line)
41
+ self.proc.stdin.flush()
42
+ if is_notification:
43
+ return None
44
+
45
+ while True:
46
+ resp_line = self.proc.stdout.readline()
47
+ if not resp_line:
48
+ err = self.proc.stderr.read() if self.proc.stderr else ""
49
+ raise RuntimeError(f"MCP server '{self.name}' terminated unexpectedly: {err}")
50
+ resp_line = resp_line.strip()
51
+ if not resp_line:
52
+ continue
53
+ try:
54
+ data = json.loads(resp_line)
55
+ if data.get("id") == req_id:
56
+ if "error" in data:
57
+ err_msg = data["error"].get("message", str(data["error"]))
58
+ raise RuntimeError(f"MCP error: {err_msg}")
59
+ return data.get("result", {})
60
+ except json.JSONDecodeError:
61
+ continue
62
+
63
+ def _init_server(self):
64
+ self._send(
65
+ "initialize",
66
+ {
67
+ "protocolVersion": "2024-11-05",
68
+ "capabilities": {},
69
+ "clientInfo": {"name": "yunta", "version": "0.8.0"},
70
+ },
71
+ )
72
+ self._send("notifications/initialized", is_notification=True)
73
+
74
+ def list_tools(self) -> list[dict]:
75
+ res = self._send("tools/list", {}) or {}
76
+ return res.get("tools", [])
77
+
78
+ def call_tool(self, tool_name: str, arguments: dict) -> str:
79
+ res = self._send("tools/call", {"name": tool_name, "arguments": arguments}) or {}
80
+ contents = res.get("content", [])
81
+ parts = []
82
+ for c in contents:
83
+ if isinstance(c, dict):
84
+ parts.append(c.get("text", json.dumps(c)))
85
+ else:
86
+ parts.append(str(c))
87
+ return "\n".join(parts) if parts else json.dumps(res)
88
+
89
+ def close(self):
90
+ try:
91
+ if self.proc.stdin:
92
+ self.proc.stdin.close()
93
+ self.proc.terminate()
94
+ self.proc.wait(timeout=2)
95
+ except Exception:
96
+ try:
97
+ self.proc.kill()
98
+ except Exception:
99
+ pass
100
+
101
+
102
+ def load_mcp_servers(config_path: str = ".yunta/mcp.json") -> list[MCPClient]:
103
+ path = Path(config_path)
104
+ if not path.exists():
105
+ return []
106
+ try:
107
+ cfg = json.loads(path.read_text(encoding="utf-8"))
108
+ except Exception as e:
109
+ print(f"advertencia: fallo al leer {config_path}: {e}")
110
+ return []
111
+
112
+ servers = cfg.get("mcpServers", {})
113
+ clients = []
114
+ for s_name, s_cfg in servers.items():
115
+ cmd = s_cfg.get("command")
116
+ if not cmd:
117
+ continue
118
+ try:
119
+ client = MCPClient(
120
+ name=s_name,
121
+ command=cmd,
122
+ args=s_cfg.get("args"),
123
+ env=s_cfg.get("env"),
124
+ )
125
+ for t in client.list_tools():
126
+ original_name = t.get("name", "")
127
+ tool_name = f"mcp__{s_name}__{original_name}"
128
+ desc = t.get("description", "")
129
+ schema = t.get("inputSchema", {"type": "object", "properties": {}})
130
+
131
+ def make_handler(c: MCPClient, orig_n: str):
132
+ def handler(raw: str) -> str:
133
+ args = _parse(raw)
134
+ return c.call_tool(orig_n, args)
135
+ return handler
136
+
137
+ registry._tools[tool_name] = Tool(
138
+ name=tool_name,
139
+ description=f"[MCP:{s_name}] {desc}",
140
+ parameters=schema,
141
+ fn=make_handler(client, original_name),
142
+ requires_approval=s_cfg.get("requires_approval", False),
143
+ )
144
+ clients.append(client)
145
+ except Exception as e:
146
+ print(f"advertencia: no se pudo iniciar servidor MCP '{s_name}': {e}")
147
+ return clients