kiwimatecoder 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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,45 @@
1
+ """MCP (Model Context Protocol) client support.
2
+
3
+ Connects to configured MCP servers over stdio or streamable HTTP, discovers
4
+ their tools, and registers them as ``mcp__<server>__<tool>`` in the shared tool
5
+ registry. The ``mcp_servers`` config section holds one spec per server: a
6
+ ``command`` (+ ``args``/``env``) for stdio, or a ``url`` (+ ``headers``) for
7
+ HTTP. Authentication is whatever headers or environment the user configures;
8
+ interactive OAuth is not implemented.
9
+ """
10
+
11
+ from kiwimatecoder.mcp.client import (
12
+ HttpMcpClient,
13
+ McpClient,
14
+ McpError,
15
+ StdioMcpClient,
16
+ )
17
+ from kiwimatecoder.mcp.manager import (
18
+ McpLoadResult,
19
+ McpManager,
20
+ get_manager,
21
+ mcp_tool_name,
22
+ set_manager,
23
+ )
24
+ from kiwimatecoder.mcp.protocol import (
25
+ CLIENT_INFO,
26
+ MCP_PROTOCOL_VERSION,
27
+ flatten_content,
28
+ parse_sse_messages,
29
+ )
30
+
31
+ __all__ = [
32
+ "CLIENT_INFO",
33
+ "HttpMcpClient",
34
+ "MCP_PROTOCOL_VERSION",
35
+ "McpClient",
36
+ "McpError",
37
+ "McpLoadResult",
38
+ "McpManager",
39
+ "StdioMcpClient",
40
+ "flatten_content",
41
+ "get_manager",
42
+ "mcp_tool_name",
43
+ "parse_sse_messages",
44
+ "set_manager",
45
+ ]
@@ -0,0 +1,385 @@
1
+ """MCP clients over stdio subprocesses and streamable HTTP.
2
+
3
+ Both transports share the JSON-RPC request/response plumbing in
4
+ :class:`JsonRpcClient`; a transport only has to implement ``_exchange``. The
5
+ protocol is intentionally minimal: one request in flight at a time, responses
6
+ matched by ``id``, and bounded waits so a hung server can never wedge a
7
+ session. Interactive OAuth is out of scope; HTTP auth is whatever headers the
8
+ user configures.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import queue
16
+ import subprocess
17
+ import threading
18
+ import time
19
+ from collections import deque
20
+ from collections.abc import Mapping, Sequence
21
+ from typing import Any, Protocol
22
+
23
+ import httpx
24
+
25
+ from kiwimatecoder.mcp.errors import McpError
26
+ from kiwimatecoder.mcp.protocol import (
27
+ CLIENT_INFO,
28
+ MCP_PROTOCOL_VERSION,
29
+ flatten_content,
30
+ flatten_resource_contents,
31
+ notification_message,
32
+ parse_response,
33
+ parse_sse_messages,
34
+ request_message,
35
+ )
36
+
37
+ INITIALIZE_TIMEOUT = 10.0
38
+ CALL_TIMEOUT = 60.0
39
+ STDERR_TAIL_LINES = 100
40
+
41
+
42
+ class McpClient(Protocol):
43
+ """The surface the manager needs from any MCP transport."""
44
+
45
+ capabilities: dict[str, Any]
46
+ server_info: dict[str, Any]
47
+
48
+ def initialize(self) -> dict[str, Any]: ...
49
+ def list_tools(self) -> list[dict[str, Any]]: ...
50
+ def call_tool(self, name: str, arguments: dict[str, Any]) -> str: ...
51
+ def list_resources(self) -> list[dict[str, Any]]: ...
52
+ def read_resource(self, uri: str) -> str: ...
53
+ def close(self) -> None: ...
54
+
55
+
56
+ class JsonRpcClient:
57
+ """Shared initialize/tools/resources calls over an abstract transport."""
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ initialize_timeout: float = INITIALIZE_TIMEOUT,
63
+ call_timeout: float = CALL_TIMEOUT,
64
+ ) -> None:
65
+ self.initialize_timeout = initialize_timeout
66
+ self.call_timeout = call_timeout
67
+ self.server_info: dict[str, Any] = {}
68
+ self.capabilities: dict[str, Any] = {}
69
+ self._next_id = 0
70
+
71
+ def _exchange(
72
+ self, message: dict[str, Any], timeout: float
73
+ ) -> dict[str, Any] | None:
74
+ raise NotImplementedError
75
+
76
+ def _request(
77
+ self, method: str, params: dict[str, Any] | None, timeout: float
78
+ ) -> Any:
79
+ self._next_id += 1
80
+ message = request_message(self._next_id, method, params)
81
+ response = self._exchange(message, timeout)
82
+ if response is None:
83
+ raise McpError(f"{method}: no response from server")
84
+ return parse_response(response, method)
85
+
86
+ def _notify(self, method: str, params: dict[str, Any] | None = None) -> None:
87
+ self._exchange(notification_message(method, params), self.initialize_timeout)
88
+
89
+ def initialize(self) -> dict[str, Any]:
90
+ """Perform the MCP handshake and cache server info/capabilities."""
91
+ result = self._request(
92
+ "initialize",
93
+ {
94
+ "protocolVersion": MCP_PROTOCOL_VERSION,
95
+ "capabilities": {},
96
+ "clientInfo": CLIENT_INFO,
97
+ },
98
+ self.initialize_timeout,
99
+ )
100
+ if isinstance(result, dict):
101
+ server_info = result.get("serverInfo")
102
+ capabilities = result.get("capabilities")
103
+ self.server_info = server_info if isinstance(server_info, dict) else {}
104
+ self.capabilities = capabilities if isinstance(capabilities, dict) else {}
105
+ else:
106
+ result = {}
107
+ self._notify("notifications/initialized")
108
+ return result
109
+
110
+ def list_tools(self) -> list[dict[str, Any]]:
111
+ result = self._request("tools/list", {}, self.call_timeout)
112
+ raw = result.get("tools") if isinstance(result, dict) else None
113
+ if not isinstance(raw, list):
114
+ return []
115
+ return [tool for tool in raw if isinstance(tool, dict)]
116
+
117
+ def call_tool(self, name: str, arguments: dict[str, Any]) -> str:
118
+ """Call a tool and return its flattened content.
119
+
120
+ Raises :class:`McpError` when the server reports ``isError`` so the
121
+ registered tool can surface a failed ``ToolResult``.
122
+ """
123
+ result = self._request(
124
+ "tools/call", {"name": name, "arguments": dict(arguments)}, self.call_timeout
125
+ )
126
+ payload = result if isinstance(result, dict) else {}
127
+ content = flatten_content(payload.get("content"))
128
+ if payload.get("isError"):
129
+ raise McpError(content or f"tool '{name}' reported an error")
130
+ return content
131
+
132
+ def list_resources(self) -> list[dict[str, Any]]:
133
+ result = self._request("resources/list", {}, self.call_timeout)
134
+ raw = result.get("resources") if isinstance(result, dict) else None
135
+ if not isinstance(raw, list):
136
+ return []
137
+ return [resource for resource in raw if isinstance(resource, dict)]
138
+
139
+ def read_resource(self, uri: str) -> str:
140
+ result = self._request("resources/read", {"uri": uri}, self.call_timeout)
141
+ payload = result if isinstance(result, dict) else {}
142
+ return flatten_resource_contents(payload.get("contents"))
143
+
144
+ def close(self) -> None: # pragma: no cover - overridden by transports
145
+ pass
146
+
147
+
148
+ class StdioMcpClient(JsonRpcClient):
149
+ """Talk to an MCP server that speaks newline-delimited JSON-RPC on stdio.
150
+
151
+ A background reader thread feeds stdout lines into a queue so requests can
152
+ time out instead of blocking forever, and a second thread drains stderr
153
+ into a bounded tail used for diagnostics (draining is what keeps a chatty
154
+ server from filling its pipe buffer and deadlocking).
155
+ """
156
+
157
+ def __init__(
158
+ self,
159
+ command: str,
160
+ args: Sequence[str] | None = None,
161
+ env: Mapping[str, str] | None = None,
162
+ *,
163
+ initialize_timeout: float = INITIALIZE_TIMEOUT,
164
+ call_timeout: float = CALL_TIMEOUT,
165
+ stderr_limit: int = STDERR_TAIL_LINES,
166
+ ) -> None:
167
+ super().__init__(
168
+ initialize_timeout=initialize_timeout, call_timeout=call_timeout
169
+ )
170
+ self.command = command
171
+ self.args = [str(arg) for arg in (args or [])]
172
+ self._stderr_tail: deque[str] = deque(maxlen=stderr_limit)
173
+ self._lines: queue.Queue[str | None] = queue.Queue()
174
+ self._write_lock = threading.Lock()
175
+ self._closed = False
176
+ process_env = os.environ.copy()
177
+ if env:
178
+ process_env.update({str(key): str(value) for key, value in env.items()})
179
+ try:
180
+ self._process = subprocess.Popen(
181
+ [command, *self.args],
182
+ stdin=subprocess.PIPE,
183
+ stdout=subprocess.PIPE,
184
+ stderr=subprocess.PIPE,
185
+ text=True,
186
+ encoding="utf-8",
187
+ env=process_env,
188
+ bufsize=1,
189
+ )
190
+ except OSError as exc:
191
+ raise McpError(f"could not start '{command}': {exc}") from exc
192
+ self._reader = threading.Thread(
193
+ target=self._read_stdout, name=f"mcp-stdout-{command}", daemon=True
194
+ )
195
+ self._reader.start()
196
+ self._stderr_reader = threading.Thread(
197
+ target=self._read_stderr, name="mcp-stderr", daemon=True
198
+ )
199
+ self._stderr_reader.start()
200
+
201
+ @property
202
+ def running(self) -> bool:
203
+ """Whether the server process is still alive."""
204
+ return self._process.poll() is None
205
+
206
+ def stderr_tail(self) -> str:
207
+ """The most recent stderr lines, joined (bounded)."""
208
+ return "\n".join(self._stderr_tail)
209
+
210
+ def _read_stdout(self) -> None:
211
+ stream = self._process.stdout
212
+ if stream is None:
213
+ self._lines.put(None)
214
+ return
215
+ try:
216
+ for line in stream:
217
+ self._lines.put(line)
218
+ except (OSError, ValueError):
219
+ pass
220
+ finally:
221
+ self._lines.put(None)
222
+
223
+ def _read_stderr(self) -> None:
224
+ stream = self._process.stderr
225
+ if stream is None:
226
+ return
227
+ try:
228
+ for line in stream:
229
+ self._stderr_tail.append(line.rstrip("\n"))
230
+ except (OSError, ValueError):
231
+ pass
232
+
233
+ def _exit_message(self) -> str:
234
+ message = "MCP server exited"
235
+ if self._process.returncode is not None:
236
+ message += f" (code {self._process.returncode})"
237
+ tail = self.stderr_tail()
238
+ if tail:
239
+ message += f": {tail}"
240
+ return message
241
+
242
+ def _exchange(
243
+ self, message: dict[str, Any], timeout: float
244
+ ) -> dict[str, Any] | None:
245
+ if self._closed:
246
+ raise McpError("MCP client is closed")
247
+ line = json.dumps(message) + "\n"
248
+ with self._write_lock:
249
+ stdin = self._process.stdin
250
+ if stdin is None or self._process.poll() is not None:
251
+ raise McpError(self._exit_message())
252
+ try:
253
+ stdin.write(line)
254
+ stdin.flush()
255
+ except (OSError, ValueError) as exc:
256
+ raise McpError(f"could not write to MCP server: {exc}") from exc
257
+ if "id" not in message:
258
+ return None
259
+ deadline = time.monotonic() + timeout
260
+ while True:
261
+ remaining = deadline - time.monotonic()
262
+ if remaining <= 0:
263
+ raise McpError(
264
+ f"timed out waiting for MCP response after {timeout:g}s"
265
+ )
266
+ try:
267
+ response_line = self._lines.get(timeout=remaining)
268
+ except queue.Empty:
269
+ raise McpError(
270
+ f"timed out waiting for MCP response after {timeout:g}s"
271
+ ) from None
272
+ if response_line is None:
273
+ raise McpError(self._exit_message())
274
+ try:
275
+ response = json.loads(response_line)
276
+ except json.JSONDecodeError:
277
+ continue
278
+ if (
279
+ isinstance(response, dict)
280
+ and response.get("id") == message["id"]
281
+ and ("result" in response or "error" in response)
282
+ ):
283
+ return response
284
+
285
+ def close(self) -> None:
286
+ """Terminate the server process, escalating to kill on timeout."""
287
+ if self._closed:
288
+ return
289
+ self._closed = True
290
+ process = self._process
291
+ try:
292
+ if process.stdin is not None:
293
+ try:
294
+ process.stdin.close()
295
+ except OSError:
296
+ pass
297
+ process.terminate()
298
+ try:
299
+ process.wait(timeout=5)
300
+ except subprocess.TimeoutExpired:
301
+ process.kill()
302
+ try:
303
+ process.wait(timeout=5)
304
+ except subprocess.TimeoutExpired:
305
+ pass
306
+ except OSError:
307
+ pass
308
+
309
+
310
+ class HttpMcpClient(JsonRpcClient):
311
+ """POST JSON-RPC to a streamable-HTTP MCP endpoint.
312
+
313
+ Responses may be a plain JSON body or an SSE stream; both are accepted.
314
+ One :class:`httpx.Client` is reused for the life of the connection, with
315
+ configured headers carrying any bearer/API auth.
316
+ """
317
+
318
+ def __init__(
319
+ self,
320
+ url: str,
321
+ headers: Mapping[str, str] | None = None,
322
+ *,
323
+ initialize_timeout: float = INITIALIZE_TIMEOUT,
324
+ call_timeout: float = CALL_TIMEOUT,
325
+ transport: httpx.BaseTransport | None = None,
326
+ ) -> None:
327
+ super().__init__(
328
+ initialize_timeout=initialize_timeout, call_timeout=call_timeout
329
+ )
330
+ self.url = url
331
+ request_headers = {
332
+ "Accept": "application/json, text/event-stream",
333
+ "Content-Type": "application/json",
334
+ }
335
+ if headers:
336
+ request_headers.update(
337
+ {str(key): str(value) for key, value in headers.items()}
338
+ )
339
+ self._client = httpx.Client(
340
+ headers=request_headers,
341
+ timeout=httpx.Timeout(call_timeout, connect=initialize_timeout),
342
+ transport=transport,
343
+ follow_redirects=True,
344
+ )
345
+
346
+ def _exchange(
347
+ self, message: dict[str, Any], timeout: float
348
+ ) -> dict[str, Any] | None:
349
+ try:
350
+ response = self._client.post(self.url, json=message, timeout=timeout)
351
+ except httpx.HTTPError as exc:
352
+ raise McpError(f"HTTP request to {self.url} failed: {exc}") from exc
353
+ if response.status_code >= 400:
354
+ body = response.text[:200].replace("\n", " ").strip()
355
+ raise McpError(
356
+ f"HTTP {response.status_code} from {self.url}"
357
+ + (f": {body}" if body else "")
358
+ )
359
+ if "id" not in message:
360
+ return None
361
+ return self._parse_body(response, message["id"])
362
+
363
+ @staticmethod
364
+ def _parse_body(response: httpx.Response, request_id: Any) -> dict[str, Any]:
365
+ text = response.text or ""
366
+ stripped = text.lstrip()
367
+ if stripped.startswith(("{", "[")):
368
+ try:
369
+ payload = json.loads(text)
370
+ except json.JSONDecodeError:
371
+ payload = None
372
+ if isinstance(payload, dict):
373
+ if "result" in payload or "error" in payload:
374
+ return payload
375
+ elif isinstance(payload, list):
376
+ for item in payload:
377
+ if isinstance(item, dict) and item.get("id") == request_id:
378
+ return item
379
+ for item in parse_sse_messages(text):
380
+ if item.get("id") == request_id:
381
+ return item
382
+ raise McpError("MCP server returned no JSON-RPC response")
383
+
384
+ def close(self) -> None:
385
+ self._client.close()
@@ -0,0 +1,7 @@
1
+ """Errors raised by the MCP client layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class McpError(Exception):
7
+ """Any MCP transport, protocol, or server-reported failure."""
@@ -0,0 +1,253 @@
1
+ """MCP server lifecycle: connect, register tools, and tear down cleanly.
2
+
3
+ The manager reads the ``mcp_servers`` config section, connects each enabled
4
+ server, discovers its tools, and registers them in the shared tool registry as
5
+ ``mcp__<server>__<tool>``. Servers that fail to start or initialize are
6
+ recorded and skipped; loading never raises, so a broken MCP entry cannot stop a
7
+ session from starting. A module-level slot lets the REPL install the session's
8
+ manager so slash commands can inspect and reload it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+ from rich.console import Console
18
+ from rich.markup import escape
19
+
20
+ from kiwimatecoder import config, events, tools
21
+ from kiwimatecoder.mcp.client import (
22
+ HttpMcpClient,
23
+ McpClient,
24
+ McpError,
25
+ StdioMcpClient,
26
+ )
27
+ from kiwimatecoder.redaction import redact
28
+ from kiwimatecoder.tools.base import FunctionTool, ToolResult
29
+
30
+ MCP_SOURCE_PREFIX = "mcp:"
31
+
32
+
33
+ def _sanitize(identifier: str) -> str:
34
+ """Reduce an arbitrary server/tool name to ``[A-Za-z0-9_]``."""
35
+ cleaned = "".join(
36
+ ch if (ch.isascii() and (ch.isalnum() or ch == "_")) else "_"
37
+ for ch in identifier
38
+ )
39
+ return cleaned or "unnamed"
40
+
41
+
42
+ def mcp_tool_name(server: str, tool: str) -> str:
43
+ """Return the registry name for a tool exposed by ``server``."""
44
+ return f"mcp__{_sanitize(server)}__{_sanitize(tool)}"
45
+
46
+
47
+ @dataclass
48
+ class McpLoadResult:
49
+ """Outcome of one :meth:`McpManager.load` pass."""
50
+
51
+ servers: list[str] = field(default_factory=list)
52
+ failed: list[tuple[str, str]] = field(default_factory=list)
53
+ tools: list[str] = field(default_factory=list)
54
+
55
+
56
+ class McpManager:
57
+ """Owns live MCP clients and the tools registered on their behalf."""
58
+
59
+ def __init__(
60
+ self, console: Console | None = None, bus: events.EventBus | None = None
61
+ ) -> None:
62
+ self._console = console if console is not None else Console()
63
+ self._bus = bus
64
+ self._clients: dict[str, McpClient] = {}
65
+ self._tools: dict[str, list[str]] = {}
66
+ self._failures: dict[str, str] = {}
67
+ self._resource_counts: dict[str, int] = {}
68
+
69
+ @property
70
+ def clients(self) -> dict[str, McpClient]:
71
+ """A copy of the connected server -> client map."""
72
+ return dict(self._clients)
73
+
74
+ def client(self, name: str) -> McpClient | None:
75
+ """Return the live client for ``name``, if connected."""
76
+ return self._clients.get(name)
77
+
78
+ def is_connected(self, name: str) -> bool:
79
+ return name in self._clients
80
+
81
+ def tools_for(self, name: str) -> list[str]:
82
+ """Registered tool names contributed by ``name``."""
83
+ return list(self._tools.get(name, []))
84
+
85
+ def failure_for(self, name: str) -> str | None:
86
+ """The recorded failure reason for ``name``, if it failed to load."""
87
+ return self._failures.get(name)
88
+
89
+ def resource_count(self, name: str) -> int | None:
90
+ """Number of resources discovered on ``name`` (0 when unsupported)."""
91
+ return self._resource_counts.get(name)
92
+
93
+ def load(
94
+ self, servers: dict[str, Any] | None = None
95
+ ) -> McpLoadResult:
96
+ """Connect enabled servers and register their tools. Never raises."""
97
+ self.shutdown()
98
+ if servers is None:
99
+ servers = config.get_mcp_servers()
100
+ result = McpLoadResult()
101
+ for name in sorted(servers):
102
+ spec = servers[name]
103
+ if not isinstance(spec, dict) or spec.get("disabled"):
104
+ continue
105
+ client: McpClient | None = None
106
+ try:
107
+ client = self._build_client(name, spec)
108
+ client.initialize()
109
+ tool_defs = client.list_tools()
110
+ except Exception as exc:
111
+ if client is not None:
112
+ self._close_quietly(client)
113
+ self._record_failure(name, exc, result)
114
+ continue
115
+ self._clients[name] = client
116
+ try:
117
+ registered = self._register_tools(name, client, tool_defs, result)
118
+ except Exception as exc:
119
+ self._unregister_tools(name)
120
+ self._clients.pop(name, None)
121
+ self._close_quietly(client)
122
+ self._record_failure(name, exc, result)
123
+ continue
124
+ self._tools[name] = registered
125
+ self._resource_counts[name] = self._count_resources(client)
126
+ result.servers.append(name)
127
+ result.tools.extend(registered)
128
+ return result
129
+
130
+ def reload(self) -> McpLoadResult:
131
+ """Drop every connection and load the configured servers again."""
132
+ return self.load()
133
+
134
+ def shutdown(self) -> None:
135
+ """Close every client, unregister its tools, and clear state."""
136
+ for name in list(self._clients):
137
+ client = self._clients.pop(name, None)
138
+ if client is not None:
139
+ self._close_quietly(client)
140
+ for name in list(self._tools):
141
+ self._unregister_tools(name)
142
+ self._failures.clear()
143
+ self._resource_counts.clear()
144
+
145
+ def _build_client(self, name: str, spec: dict[str, Any]) -> McpClient:
146
+ if spec.get("command"):
147
+ return StdioMcpClient(
148
+ str(spec["command"]),
149
+ args=spec.get("args"),
150
+ env=spec.get("env"),
151
+ )
152
+ return HttpMcpClient(str(spec["url"]), headers=spec.get("headers"))
153
+
154
+ def _register_tools(
155
+ self,
156
+ server: str,
157
+ client: McpClient,
158
+ tool_defs: list[dict[str, Any]],
159
+ result: McpLoadResult,
160
+ ) -> list[str]:
161
+ registered: list[str] = []
162
+ for definition in tool_defs:
163
+ tool_name = str(definition.get("name") or "").strip()
164
+ if not tool_name:
165
+ continue
166
+ registry_name = mcp_tool_name(server, tool_name)
167
+ parameters = definition.get("inputSchema")
168
+ if not isinstance(parameters, dict):
169
+ parameters = {"type": "object", "properties": {}}
170
+ annotations = definition.get("annotations")
171
+ read_only = (
172
+ bool(annotations.get("readOnlyHint"))
173
+ if isinstance(annotations, dict)
174
+ else False
175
+ )
176
+ description = str(
177
+ definition.get("description")
178
+ or f"MCP tool '{tool_name}' from server '{server}'."
179
+ )
180
+ tool = FunctionTool(
181
+ name=registry_name,
182
+ description=description,
183
+ parameters=parameters,
184
+ func=self._make_caller(client, tool_name),
185
+ writes=not read_only,
186
+ runs=False,
187
+ )
188
+ try:
189
+ tools.register_tool(tool, source=f"{MCP_SOURCE_PREFIX}{server}")
190
+ except ValueError as exc:
191
+ result.failed.append((server, f"{registry_name}: {exc}"))
192
+ continue
193
+ registered.append(registry_name)
194
+ return registered
195
+
196
+ def _make_caller(
197
+ self, client: McpClient, tool_name: str
198
+ ) -> Callable[[dict[str, Any], Any], ToolResult]:
199
+ def call(arguments: dict[str, Any], session: Any) -> ToolResult:
200
+ try:
201
+ content = client.call_tool(tool_name, arguments)
202
+ except McpError as exc:
203
+ return ToolResult.error(str(exc))
204
+ return ToolResult(content=content or "(no output)")
205
+
206
+ return call
207
+
208
+ def _count_resources(self, client: McpClient) -> int:
209
+ capabilities = client.capabilities or {}
210
+ if "resources" not in capabilities:
211
+ return 0
212
+ try:
213
+ return len(client.list_resources())
214
+ except Exception:
215
+ return 0
216
+
217
+ def _unregister_tools(self, name: str) -> None:
218
+ for tool_name in self._tools.pop(name, []):
219
+ try:
220
+ tools.unregister_tool(tool_name)
221
+ except Exception:
222
+ pass
223
+
224
+ def _record_failure(
225
+ self, name: str, exc: BaseException, result: McpLoadResult
226
+ ) -> None:
227
+ reason = redact(str(exc)) or exc.__class__.__name__
228
+ self._failures[name] = reason
229
+ result.failed.append((name, reason))
230
+ self._console.print(
231
+ f"[dim]MCP server '{escape(name)}' failed: {escape(reason)}[/dim]"
232
+ )
233
+
234
+ @staticmethod
235
+ def _close_quietly(client: McpClient) -> None:
236
+ try:
237
+ client.close()
238
+ except Exception:
239
+ pass
240
+
241
+
242
+ _MANAGER: McpManager | None = None
243
+
244
+
245
+ def get_manager() -> McpManager | None:
246
+ """Return the session's MCP manager, if the REPL installed one."""
247
+ return _MANAGER
248
+
249
+
250
+ def set_manager(manager: McpManager | None) -> None:
251
+ """Install (or clear) the session's MCP manager."""
252
+ global _MANAGER
253
+ _MANAGER = manager