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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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,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
|