turnloop 0.1.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.
Files changed (132) hide show
  1. turnloop/__init__.py +3 -0
  2. turnloop/__main__.py +4 -0
  3. turnloop/agent/__init__.py +0 -0
  4. turnloop/agent/factory.py +175 -0
  5. turnloop/agent/headless.py +150 -0
  6. turnloop/agent/loop.py +396 -0
  7. turnloop/agent/subagent.py +182 -0
  8. turnloop/agent/system_prompt.py +263 -0
  9. turnloop/cli.py +202 -0
  10. turnloop/commands/__init__.py +0 -0
  11. turnloop/commands/dispatch.py +428 -0
  12. turnloop/commands/loader.py +140 -0
  13. turnloop/config.py +425 -0
  14. turnloop/context/__init__.py +0 -0
  15. turnloop/context/budget.py +74 -0
  16. turnloop/context/compaction.py +441 -0
  17. turnloop/context/memory.py +120 -0
  18. turnloop/core/__init__.py +0 -0
  19. turnloop/core/events.py +145 -0
  20. turnloop/core/ids.py +29 -0
  21. turnloop/core/messages.py +217 -0
  22. turnloop/core/tokens.py +104 -0
  23. turnloop/diagnostics.py +233 -0
  24. turnloop/errors.py +64 -0
  25. turnloop/experiments/__init__.py +0 -0
  26. turnloop/experiments/configs/bakeoff.yaml +50 -0
  27. turnloop/experiments/configs/ceiling-check.yaml +31 -0
  28. turnloop/experiments/configs/context-glm.yaml +59 -0
  29. turnloop/experiments/configs/context.yaml +65 -0
  30. turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
  31. turnloop/experiments/configs/groq-free.yaml +28 -0
  32. turnloop/experiments/configs/hard-calibration.yaml +26 -0
  33. turnloop/experiments/configs/hard-glm.yaml +41 -0
  34. turnloop/experiments/configs/loop.yaml +34 -0
  35. turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
  36. turnloop/experiments/configs/reliability.yaml +44 -0
  37. turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
  38. turnloop/experiments/configs/smoke.yaml +22 -0
  39. turnloop/experiments/graders.py +209 -0
  40. turnloop/experiments/report.py +371 -0
  41. turnloop/experiments/runner.py +495 -0
  42. turnloop/experiments/suite/default.yaml +185 -0
  43. turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
  44. turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
  45. turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
  46. turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
  47. turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
  48. turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
  49. turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
  50. turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
  51. turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
  52. turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
  53. turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
  54. turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
  55. turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
  56. turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
  57. turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
  58. turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
  59. turnloop/experiments/suite/fixtures/empty/.keep +1 -0
  60. turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
  61. turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
  62. turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
  63. turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
  64. turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
  65. turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
  66. turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
  67. turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
  68. turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
  69. turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
  70. turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
  71. turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
  72. turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
  73. turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
  74. turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
  75. turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
  76. turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
  77. turnloop/experiments/suite/fixtures/rename/report.py +5 -0
  78. turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
  79. turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
  80. turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
  81. turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
  82. turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
  83. turnloop/experiments/suite/fixtures/util/util.py +9 -0
  84. turnloop/hooks/__init__.py +0 -0
  85. turnloop/hooks/runner.py +221 -0
  86. turnloop/mcp/__init__.py +0 -0
  87. turnloop/mcp/adapter.py +109 -0
  88. turnloop/mcp/client.py +299 -0
  89. turnloop/permissions/__init__.py +0 -0
  90. turnloop/permissions/engine.py +211 -0
  91. turnloop/permissions/rules.py +325 -0
  92. turnloop/providers/__init__.py +0 -0
  93. turnloop/providers/anthropic.py +318 -0
  94. turnloop/providers/base.py +329 -0
  95. turnloop/providers/gemini.py +217 -0
  96. turnloop/providers/mock.py +226 -0
  97. turnloop/providers/openai_compat.py +357 -0
  98. turnloop/providers/pricing.py +148 -0
  99. turnloop/providers/registry.py +75 -0
  100. turnloop/providers/sse.py +114 -0
  101. turnloop/sessions/__init__.py +0 -0
  102. turnloop/sessions/models.py +139 -0
  103. turnloop/sessions/store.py +271 -0
  104. turnloop/tools/__init__.py +0 -0
  105. turnloop/tools/ask.py +133 -0
  106. turnloop/tools/base.py +286 -0
  107. turnloop/tools/bash.py +423 -0
  108. turnloop/tools/builtin.py +63 -0
  109. turnloop/tools/edit.py +238 -0
  110. turnloop/tools/glob.py +235 -0
  111. turnloop/tools/grep.py +314 -0
  112. turnloop/tools/read.py +151 -0
  113. turnloop/tools/runner.py +284 -0
  114. turnloop/tools/shell.py +222 -0
  115. turnloop/tools/skill.py +89 -0
  116. turnloop/tools/task.py +161 -0
  117. turnloop/tools/textio.py +87 -0
  118. turnloop/tools/todo.py +140 -0
  119. turnloop/tools/webfetch.py +235 -0
  120. turnloop/tools/write.py +97 -0
  121. turnloop/tui/__init__.py +0 -0
  122. turnloop/tui/app.py +330 -0
  123. turnloop/tui/app.tcss +84 -0
  124. turnloop/tui/bridge.py +142 -0
  125. turnloop/tui/widgets/__init__.py +0 -0
  126. turnloop/tui/widgets/permission.py +131 -0
  127. turnloop/tui/widgets/status.py +110 -0
  128. turnloop/tui/widgets/transcript.py +146 -0
  129. turnloop-0.1.0.dist-info/METADATA +916 -0
  130. turnloop-0.1.0.dist-info/RECORD +132 -0
  131. turnloop-0.1.0.dist-info/WHEEL +4 -0
  132. turnloop-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,221 @@
1
+ """Hooks: user-configured commands that run at defined points.
2
+
3
+ The protocol is JSON on stdin, and three ways to respond:
4
+
5
+ * exit 0 — proceed. stdout is ignored unless it is JSON.
6
+ * exit 2 — block. stderr becomes the reason the model is told.
7
+ * exit 0 with JSON stdout — structured control:
8
+ {"decision": "block"|"approve", "reason": ..., "additionalContext": ...}
9
+
10
+ Two rules that matter. Hooks run through the same shell resolution as the Bash
11
+ tool, so a hook written as a POSIX one-liner does not hit the WSL stub on Windows
12
+ either. And a hook that crashes, hangs or writes garbage is logged and ignored: a
13
+ broken hook must not be able to take the session down with it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import subprocess
20
+ from dataclasses import dataclass, field
21
+ from fnmatch import fnmatchcase
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import anyio
26
+
27
+ from turnloop.config import HookSpec, Settings
28
+ from turnloop.tools.shell import child_env, decode_output, kill_tree, resolve_shell, spawn_kwargs
29
+
30
+ HOOK_EVENTS = (
31
+ "SessionStart",
32
+ "UserPromptSubmit",
33
+ "PreToolUse",
34
+ "PostToolUse",
35
+ "Stop",
36
+ "SubagentStop",
37
+ "PreCompact",
38
+ "SessionEnd",
39
+ )
40
+
41
+
42
+ @dataclass
43
+ class HookOutcome:
44
+ blocked: bool = False
45
+ reason: str = ""
46
+ additional_context: str = ""
47
+ outputs: list[str] = field(default_factory=list)
48
+
49
+
50
+ class HookRunner:
51
+ def __init__(self, settings: Settings, cwd: Path, session_id: str = ""):
52
+ self.settings = settings
53
+ self.cwd = cwd
54
+ self.session_id = session_id
55
+ self.log: list[dict] = []
56
+
57
+ # --- event entry points ------------------------------------------------
58
+
59
+ async def run_pre_tool(self, tool_name: str, args, ctx) -> HookOutcome | None:
60
+ payload = {
61
+ "event": "PreToolUse",
62
+ "tool_name": tool_name,
63
+ "tool_input": _dump(args),
64
+ "cwd": str(self.cwd),
65
+ "session_id": self.session_id,
66
+ }
67
+ return await self._run_event("PreToolUse", payload, matcher_value=tool_name)
68
+
69
+ async def run_post_tool(self, tool_name: str, args, output, ctx) -> HookOutcome | None:
70
+ payload = {
71
+ "event": "PostToolUse",
72
+ "tool_name": tool_name,
73
+ "tool_input": _dump(args),
74
+ "tool_output": (output.content or "")[:8_000],
75
+ "is_error": output.is_error,
76
+ "cwd": str(self.cwd),
77
+ "session_id": self.session_id,
78
+ }
79
+ return await self._run_event("PostToolUse", payload, matcher_value=tool_name)
80
+
81
+ async def run_user_prompt(self, prompt: str, session) -> HookOutcome | None:
82
+ payload = {
83
+ "event": "UserPromptSubmit",
84
+ "prompt": prompt,
85
+ "cwd": str(self.cwd),
86
+ "session_id": session.session_id,
87
+ }
88
+ return await self._run_event("UserPromptSubmit", payload)
89
+
90
+ async def run_stop(self, session, stop_reason: str) -> HookOutcome | None:
91
+ payload = {
92
+ "event": "Stop",
93
+ "stop_reason": stop_reason,
94
+ "turn": session.turn,
95
+ "session_id": session.session_id,
96
+ }
97
+ return await self._run_event("Stop", payload)
98
+
99
+ async def run_session_event(self, event: str, extra: dict | None = None) -> HookOutcome | None:
100
+ payload = {"event": event, "cwd": str(self.cwd), "session_id": self.session_id}
101
+ payload.update(extra or {})
102
+ return await self._run_event(event, payload)
103
+
104
+ # --- execution ---------------------------------------------------------
105
+
106
+ async def _run_event(self, event: str, payload: dict,
107
+ matcher_value: str | None = None) -> HookOutcome | None:
108
+ matchers = self.settings.hooks.get(event) or []
109
+ if not matchers:
110
+ return None
111
+
112
+ outcome = HookOutcome()
113
+ for matcher in matchers:
114
+ if matcher_value is not None and not _matches(matcher.matcher, matcher_value):
115
+ continue
116
+ for spec in matcher.hooks:
117
+ result = await self._run_one(spec, payload, event)
118
+ if result is None:
119
+ continue
120
+ if result.blocked:
121
+ return result
122
+ if result.additional_context:
123
+ outcome.additional_context += (
124
+ ("\n" if outcome.additional_context else "") + result.additional_context
125
+ )
126
+ outcome.outputs.extend(result.outputs)
127
+ return outcome
128
+
129
+ async def _run_one(self, spec: HookSpec, payload: dict, event: str) -> HookOutcome | None:
130
+ try:
131
+ shell = resolve_shell(self.settings.bash.shell)
132
+ except Exception as exc: # noqa: BLE001
133
+ self._record(event, spec.command, error=f"no shell: {exc}")
134
+ return None
135
+
136
+ stdin_data = json.dumps(payload, default=str).encode("utf-8")
137
+ argv = shell.argv(spec.command)
138
+
139
+ try:
140
+ process = await anyio.open_process(
141
+ argv,
142
+ cwd=str(self.cwd),
143
+ env=child_env({"TURNLOOP_HOOK_EVENT": event}),
144
+ stdin=subprocess.PIPE,
145
+ stdout=subprocess.PIPE,
146
+ stderr=subprocess.PIPE,
147
+ **spawn_kwargs(),
148
+ )
149
+ except OSError as exc:
150
+ self._record(event, spec.command, error=str(exc))
151
+ return None
152
+
153
+ stdout = b""
154
+ stderr = b""
155
+ code: int | None = None
156
+ try:
157
+ with anyio.fail_after(spec.timeout):
158
+ if process.stdin is not None:
159
+ await process.stdin.send(stdin_data)
160
+ await process.stdin.aclose()
161
+ if process.stdout is not None:
162
+ async for chunk in process.stdout:
163
+ stdout += chunk
164
+ if process.stderr is not None:
165
+ async for chunk in process.stderr:
166
+ stderr += chunk
167
+ code = await process.wait()
168
+ except TimeoutError:
169
+ kill_tree(process.pid)
170
+ self._record(event, spec.command, error=f"timed out after {spec.timeout}s")
171
+ return None
172
+ except Exception as exc: # noqa: BLE001 - a broken hook is never fatal
173
+ self._record(event, spec.command, error=str(exc))
174
+ return None
175
+ finally:
176
+ with anyio.CancelScope(shield=True):
177
+ try:
178
+ await process.aclose()
179
+ except Exception: # noqa: BLE001
180
+ pass
181
+
182
+ out_text = decode_output(stdout).strip()
183
+ err_text = decode_output(stderr).strip()
184
+ self._record(event, spec.command, code=code, stdout=out_text[:2_000], stderr=err_text[:2_000])
185
+
186
+ # Exit code 2 is the documented "block" signal.
187
+ if code == 2:
188
+ return HookOutcome(blocked=True, reason=err_text or "blocked by hook")
189
+
190
+ if out_text.startswith("{"):
191
+ try:
192
+ data = json.loads(out_text)
193
+ except json.JSONDecodeError:
194
+ return HookOutcome(outputs=[out_text])
195
+ decision = str(data.get("decision", "")).lower()
196
+ return HookOutcome(
197
+ blocked=decision == "block",
198
+ reason=str(data.get("reason", "")),
199
+ additional_context=str(data.get("additionalContext", "")),
200
+ outputs=[out_text],
201
+ )
202
+
203
+ return HookOutcome(outputs=[out_text] if out_text else [])
204
+
205
+ def _record(self, event: str, command: str, **rest: Any) -> None:
206
+ entry = {"event": event, "command": command, **rest}
207
+ self.log.append(entry)
208
+
209
+
210
+ def _matches(pattern: str, value: str) -> bool:
211
+ if pattern in ("", "*"):
212
+ return True
213
+ # A comma-separated list is more natural than several matcher blocks.
214
+ return any(fnmatchcase(value, part.strip()) for part in pattern.split(",") if part.strip())
215
+
216
+
217
+ def _dump(args) -> dict:
218
+ try:
219
+ return json.loads(args.model_dump_json())
220
+ except Exception: # noqa: BLE001
221
+ return {"repr": str(args)}
File without changes
@@ -0,0 +1,109 @@
1
+ """Wrapping MCP tools as native Tool subclasses.
2
+
3
+ The `Args` model is generated at runtime from the server's JSON Schema, so an MCP
4
+ tool validates exactly like a builtin and produces the same readable error results.
5
+
6
+ When a schema cannot be turned into a model — and third-party schemas are often
7
+ wrong — the fallback is a permissive passthrough rather than a crash. A badly
8
+ written server should cost its own tool's validation, not the whole startup.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field, create_model
16
+
17
+ from turnloop.config import Verbosity
18
+ from turnloop.mcp.client import MCPClient, MCPTool
19
+ from turnloop.tools.base import Tool, ToolContext, ToolOutput
20
+
21
+ _JSON_TO_PYTHON = {
22
+ "string": str,
23
+ "integer": int,
24
+ "number": float,
25
+ "boolean": bool,
26
+ "object": dict,
27
+ "array": list,
28
+ }
29
+
30
+
31
+ class PassthroughArgs(BaseModel):
32
+ """Accepts anything. Used when a server's schema is unusable."""
33
+
34
+ model_config = ConfigDict(extra="allow")
35
+
36
+
37
+ def build_args_model(tool: MCPTool) -> type[BaseModel]:
38
+ schema = tool.input_schema or {}
39
+ properties = schema.get("properties")
40
+ if not isinstance(properties, dict) or not properties:
41
+ return PassthroughArgs
42
+
43
+ required = set(schema.get("required") or [])
44
+ fields: dict[str, Any] = {}
45
+
46
+ for name, spec in properties.items():
47
+ if not isinstance(spec, dict) or not name.isidentifier():
48
+ return PassthroughArgs
49
+ annotation = _JSON_TO_PYTHON.get(str(spec.get("type", "")), Any)
50
+ description = str(spec.get("description", ""))[:400]
51
+ if name in required:
52
+ fields[name] = (annotation, Field(description=description))
53
+ else:
54
+ fields[name] = (annotation | None, Field(default=None, description=description))
55
+
56
+ try:
57
+ return create_model(f"MCP_{tool.server}_{tool.name}_Args", **fields)
58
+ except Exception: # noqa: BLE001
59
+ return PassthroughArgs
60
+
61
+
62
+ class MCPToolAdapter(Tool):
63
+ # MCP tools are assumed to mutate: the protocol has no read-only annotation
64
+ # that can be trusted, and guessing permissively would put unaudited
65
+ # third-party code inside plan mode.
66
+ read_only = False
67
+ parallel_safe = False
68
+
69
+ def __init__(self, tool: MCPTool, client: MCPClient):
70
+ self.tool = tool
71
+ self.client = client
72
+ self.name = tool.qualified # type: ignore[misc]
73
+ self.Args = build_args_model(tool) # type: ignore[misc]
74
+ self.timeout_s = client.config.timeout_s # type: ignore[misc]
75
+
76
+ def description(self, verbosity: Verbosity = "normal") -> str:
77
+ base = self.tool.description or f"Tool {self.tool.name} from the {self.tool.server} MCP server."
78
+ if verbosity == "terse":
79
+ return base.split("\n\n")[0][:300]
80
+ return base
81
+
82
+ def permission_target(self, args) -> str:
83
+ return self.tool.name
84
+
85
+ def summary(self, args) -> str:
86
+ return f"{self.tool.server}: {self.tool.name}"
87
+
88
+ async def run(self, args, ctx: ToolContext) -> ToolOutput:
89
+ if ctx.readonly:
90
+ return ToolOutput.error(
91
+ "plan mode is read-only, and MCP tools are treated as mutating "
92
+ "because the protocol does not declare otherwise."
93
+ )
94
+ payload = args.model_dump(exclude_none=True) if isinstance(args, BaseModel) else dict(args)
95
+ text, is_error = await self.client.call_tool(self.tool.name, payload)
96
+ return ToolOutput(
97
+ content=text,
98
+ is_error=is_error,
99
+ display=f"{self.tool.server}:{self.tool.name}",
100
+ metrics={"mcp_server": self.tool.server, "mcp_tool": self.tool.name},
101
+ )
102
+
103
+
104
+ def adapters_for(manager) -> list[MCPToolAdapter]:
105
+ out: list[MCPToolAdapter] = []
106
+ for client in manager.clients.values():
107
+ for tool in client.tools:
108
+ out.append(MCPToolAdapter(tool, client))
109
+ return out
turnloop/mcp/client.py ADDED
@@ -0,0 +1,299 @@
1
+ """Model Context Protocol client: JSON-RPC 2.0 over stdio or SSE.
2
+
3
+ Hand-rolled rather than taking the SDK, for the same reason the LLM providers are:
4
+ the protocol surface this needs is `initialize`, `tools/list`, `tools/call` and
5
+ notification handling, which is about 150 lines, and owning it means a third-party
6
+ server behaving badly is a problem we can contain rather than one that surfaces as
7
+ an SDK exception mid-turn.
8
+
9
+ The containment rule: **a dead or misbehaving MCP server degrades to "that tool is
10
+ unavailable" and never blocks the agent loop.** Connections are lazy, every call is
11
+ bounded by a timeout, and a server whose schema cannot be parsed simply contributes
12
+ no tools.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import subprocess
19
+ from dataclasses import dataclass, field
20
+ from typing import Any
21
+
22
+ import anyio
23
+ import httpx
24
+
25
+ from turnloop.config import MCPServerConfig
26
+ from turnloop.providers.sse import iter_sse
27
+ from turnloop.tools.shell import child_env
28
+
29
+ PROTOCOL_VERSION = "2025-06-18"
30
+ CLIENT_INFO = {"name": "turnloop", "version": "0.1.0"}
31
+
32
+
33
+ @dataclass
34
+ class MCPTool:
35
+ name: str # the server's own name, unprefixed
36
+ description: str
37
+ input_schema: dict
38
+ server: str
39
+
40
+ @property
41
+ def qualified(self) -> str:
42
+ return f"mcp__{self.server}__{self.name}"
43
+
44
+
45
+ @dataclass
46
+ class MCPClient:
47
+ name: str
48
+ config: MCPServerConfig
49
+ tools: list[MCPTool] = field(default_factory=list)
50
+ connected: bool = False
51
+ error: str | None = None
52
+
53
+ _process: Any = None
54
+ _http: httpx.AsyncClient | None = None
55
+ _endpoint: str | None = None
56
+ _next_id: int = 0
57
+ _lock: Any = None
58
+
59
+ def __post_init__(self) -> None:
60
+ self._lock = anyio.Lock()
61
+
62
+ # --- lifecycle ---------------------------------------------------------
63
+
64
+ async def connect(self) -> bool:
65
+ """Idempotent, lazy, and never raises.
66
+
67
+ Returns False on failure and records why, so `/mcp` can show the reason
68
+ instead of the user discovering it as a missing tool.
69
+ """
70
+ if self.connected:
71
+ return True
72
+ try:
73
+ with anyio.fail_after(self.config.timeout_s):
74
+ if self.config.transport == "stdio":
75
+ await self._connect_stdio()
76
+ else:
77
+ await self._connect_sse()
78
+ await self._initialize()
79
+ await self._load_tools()
80
+ self.connected = True
81
+ self.error = None
82
+ return True
83
+ except Exception as exc: # noqa: BLE001 - containment is the point
84
+ self.error = f"{type(exc).__name__}: {exc}"
85
+ await self.aclose()
86
+ return False
87
+
88
+ async def _connect_stdio(self) -> None:
89
+ if not self.config.command:
90
+ raise ValueError("stdio transport requires `command`")
91
+ argv = [self.config.command, *self.config.args]
92
+ self._process = await anyio.open_process(
93
+ argv,
94
+ stdin=subprocess.PIPE,
95
+ stdout=subprocess.PIPE,
96
+ stderr=subprocess.DEVNULL, # servers log freely to stderr; not our business
97
+ env=child_env(self.config.env),
98
+ )
99
+
100
+ async def _connect_sse(self) -> None:
101
+ if not self.config.url:
102
+ raise ValueError("sse transport requires `url`")
103
+ self._http = httpx.AsyncClient(
104
+ timeout=httpx.Timeout(self.config.timeout_s, connect=10.0),
105
+ headers=self.config.headers,
106
+ )
107
+ # The SSE transport advertises its POST endpoint in an `endpoint` event.
108
+ self._endpoint = self.config.url.rstrip("/") + "/message"
109
+
110
+ async def aclose(self) -> None:
111
+ if self._process is not None:
112
+ try:
113
+ self._process.terminate()
114
+ except Exception: # noqa: BLE001
115
+ pass
116
+ try:
117
+ await self._process.aclose()
118
+ except Exception: # noqa: BLE001
119
+ pass
120
+ self._process = None
121
+ if self._http is not None:
122
+ await self._http.aclose()
123
+ self._http = None
124
+ self.connected = False
125
+
126
+ # --- protocol ----------------------------------------------------------
127
+
128
+ async def _initialize(self) -> None:
129
+ await self._request(
130
+ "initialize",
131
+ {
132
+ "protocolVersion": PROTOCOL_VERSION,
133
+ "capabilities": {"tools": {}},
134
+ "clientInfo": CLIENT_INFO,
135
+ },
136
+ )
137
+ await self._notify("notifications/initialized", {})
138
+
139
+ async def _load_tools(self) -> None:
140
+ result = await self._request("tools/list", {})
141
+ self.tools = []
142
+ for entry in (result or {}).get("tools", []):
143
+ schema = entry.get("inputSchema") or {"type": "object", "properties": {}}
144
+ self.tools.append(
145
+ MCPTool(
146
+ name=entry.get("name", ""),
147
+ description=(entry.get("description") or "").strip(),
148
+ input_schema=schema,
149
+ server=self.name,
150
+ )
151
+ )
152
+
153
+ async def call_tool(self, tool: str, arguments: dict) -> tuple[str, bool]:
154
+ """Call a tool. Returns (text, is_error) — never raises."""
155
+ if not self.connected and not await self.connect():
156
+ return (f"MCP server {self.name!r} is unavailable: {self.error}", True)
157
+ try:
158
+ with anyio.fail_after(self.config.timeout_s):
159
+ result = await self._request(
160
+ "tools/call", {"name": tool, "arguments": arguments}
161
+ )
162
+ except TimeoutError:
163
+ return (f"MCP call {self.name}/{tool} timed out", True)
164
+ except Exception as exc: # noqa: BLE001
165
+ self.connected = False # force a reconnect next time
166
+ return (f"MCP call {self.name}/{tool} failed: {exc}", True)
167
+
168
+ return _render_result(result or {})
169
+
170
+ # --- transport ---------------------------------------------------------
171
+
172
+ async def _request(self, method: str, params: dict) -> dict | None:
173
+ async with self._lock:
174
+ self._next_id += 1
175
+ message = {
176
+ "jsonrpc": "2.0",
177
+ "id": self._next_id,
178
+ "method": method,
179
+ "params": params,
180
+ }
181
+ if self._process is not None:
182
+ return await self._stdio_roundtrip(message)
183
+ return await self._http_roundtrip(message)
184
+
185
+ async def _notify(self, method: str, params: dict) -> None:
186
+ message = {"jsonrpc": "2.0", "method": method, "params": params}
187
+ if self._process is not None:
188
+ await self._write_line(message)
189
+ elif self._http is not None and self._endpoint is not None:
190
+ await self._http.post(self._endpoint, json=message)
191
+
192
+ async def _write_line(self, message: dict) -> None:
193
+ assert self._process is not None and self._process.stdin is not None
194
+ await self._process.stdin.send((json.dumps(message) + "\n").encode("utf-8"))
195
+
196
+ async def _stdio_roundtrip(self, message: dict) -> dict | None:
197
+ await self._write_line(message)
198
+ assert self._process is not None and self._process.stdout is not None
199
+
200
+ buffer = b""
201
+ target_id = message["id"]
202
+ async for chunk in self._process.stdout:
203
+ buffer += chunk
204
+ while b"\n" in buffer:
205
+ line, buffer = buffer.split(b"\n", 1)
206
+ if not line.strip():
207
+ continue
208
+ try:
209
+ payload = json.loads(line.decode("utf-8", errors="replace"))
210
+ except json.JSONDecodeError:
211
+ continue # some servers print banners to stdout
212
+ if payload.get("id") != target_id:
213
+ continue # a notification or an unrelated response
214
+ if error := payload.get("error"):
215
+ raise RuntimeError(error.get("message", str(error)))
216
+ return payload.get("result")
217
+ raise RuntimeError("the MCP server closed its output stream")
218
+
219
+ async def _http_roundtrip(self, message: dict) -> dict | None:
220
+ assert self._http is not None and self._endpoint is not None
221
+ response = await self._http.post(self._endpoint, json=message)
222
+ response.raise_for_status()
223
+
224
+ content_type = response.headers.get("content-type", "")
225
+ if "text/event-stream" in content_type:
226
+ async def lines():
227
+ for line in response.text.splitlines():
228
+ yield line
229
+
230
+ async for frame in iter_sse(lines()):
231
+ payload = frame.json()
232
+ if payload and payload.get("id") == message["id"]:
233
+ if error := payload.get("error"):
234
+ raise RuntimeError(error.get("message", str(error)))
235
+ return payload.get("result")
236
+ return None
237
+
238
+ payload = response.json()
239
+ if error := payload.get("error"):
240
+ raise RuntimeError(error.get("message", str(error)))
241
+ return payload.get("result")
242
+
243
+
244
+ def _render_result(result: dict) -> tuple[str, bool]:
245
+ """Flatten MCP content blocks into text the model can read."""
246
+ is_error = bool(result.get("isError"))
247
+ parts: list[str] = []
248
+ for item in result.get("content") or []:
249
+ kind = item.get("type")
250
+ if kind == "text":
251
+ parts.append(item.get("text", ""))
252
+ elif kind == "resource":
253
+ resource = item.get("resource") or {}
254
+ parts.append(resource.get("text") or f"(resource: {resource.get('uri', '?')})")
255
+ elif kind == "image":
256
+ parts.append(f"(image: {item.get('mimeType', 'unknown')}, not displayed)")
257
+ else:
258
+ parts.append(json.dumps(item)[:2_000])
259
+ if not parts and (structured := result.get("structuredContent")):
260
+ parts.append(json.dumps(structured, indent=2)[:8_000])
261
+ return ("\n".join(p for p in parts if p) or "(no content)", is_error)
262
+
263
+
264
+ class MCPManager:
265
+ """Owns every configured server and hands their tools to the registry."""
266
+
267
+ def __init__(self, servers: dict[str, MCPServerConfig]):
268
+ self.clients = {
269
+ name: MCPClient(name=name, config=cfg)
270
+ for name, cfg in servers.items()
271
+ if cfg.enabled
272
+ }
273
+
274
+ async def connect_all(self) -> dict[str, bool]:
275
+ results: dict[str, bool] = {}
276
+
277
+ async def one(name: str, client: MCPClient) -> None:
278
+ results[name] = await client.connect()
279
+
280
+ async with anyio.create_task_group() as tg:
281
+ for name, client in self.clients.items():
282
+ tg.start_soon(one, name, client)
283
+ return results
284
+
285
+ def all_tools(self) -> list[MCPTool]:
286
+ return [tool for client in self.clients.values() for tool in client.tools]
287
+
288
+ async def aclose(self) -> None:
289
+ for client in self.clients.values():
290
+ await client.aclose()
291
+
292
+ def status(self) -> list[tuple[str, str]]:
293
+ rows = []
294
+ for name, client in self.clients.items():
295
+ if client.connected:
296
+ rows.append((name, f"{len(client.tools)} tool(s)"))
297
+ else:
298
+ rows.append((name, f"unavailable: {client.error or 'not connected'}"))
299
+ return rows
File without changes