voidx 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/main.py ADDED
@@ -0,0 +1,154 @@
1
+ """CLI entry point — `voidx` defaults to interactive chat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+
8
+ import typer
9
+
10
+ cli = typer.Typer(
11
+ name="voidx",
12
+ help="A coding agent in your terminal.",
13
+ no_args_is_help=False,
14
+ invoke_without_command=True,
15
+ )
16
+
17
+
18
+ def _vconsole():
19
+ from voidx.ui.console import VoidConsole
20
+ return VoidConsole()
21
+
22
+
23
+ async def _select_start_session(
24
+ workspace: str,
25
+ provider: str,
26
+ model: str,
27
+ resume: str | None,
28
+ new_session: bool,
29
+ vconsole,
30
+ ):
31
+ from voidx.memory.session import (
32
+ create_session,
33
+ get_session,
34
+ latest_session_for_workspace,
35
+ )
36
+
37
+ if resume:
38
+ session = await get_session(resume)
39
+ if not session:
40
+ vconsole.error(f"Session not found: {resume}")
41
+ raise typer.Exit(code=1)
42
+ title = session.title[:60] + ("..." if len(session.title) > 60 else "")
43
+ vconsole.print(f"[dim]Resumed {session.id}: {title}[/dim]")
44
+ return session
45
+
46
+ if not new_session:
47
+ session = await latest_session_for_workspace(workspace)
48
+ if session:
49
+ title = session.title[:60] + ("..." if len(session.title) > 60 else "")
50
+ vconsole.print(f"[dim]Resumed {session.id}: {title}[/dim]")
51
+ return session
52
+
53
+ return await create_session(
54
+ workspace=workspace,
55
+ provider=provider,
56
+ model=model,
57
+ )
58
+
59
+
60
+ async def _run_chat(
61
+ workspace: str = ".",
62
+ model: str | None = None,
63
+ provider: str | None = None,
64
+ resume: str | None = None,
65
+ new_session: bool = False,
66
+ ) -> None:
67
+ from voidx.ui.dock import set_dock, BottomInputDock
68
+ set_dock(BottomInputDock())
69
+
70
+ from voidx.config import Settings
71
+ from voidx.agent.graph import VoidXGraph
72
+
73
+ vconsole = _vconsole()
74
+ ws_path = str(Path(workspace).resolve())
75
+ settings = Settings(ws_path)
76
+
77
+ # Bind settings to catalog early so list_models() merges custom models
78
+ from voidx.llm.catalog import bind_settings
79
+ bind_settings(settings)
80
+
81
+ cfg = settings.build_config()
82
+ cfg.workspace = ws_path
83
+
84
+ if model:
85
+ cfg.model.model = model
86
+ if provider:
87
+ cfg.model.provider = provider
88
+
89
+ profile = settings.resolve_profile()
90
+ if profile:
91
+ api_key = profile.api_key
92
+ else:
93
+ api_key = settings.resolve_api_key(cfg.model.provider)
94
+
95
+ session = await _select_start_session(
96
+ workspace=cfg.workspace,
97
+ provider=cfg.model.provider,
98
+ model=cfg.model.model,
99
+ resume=resume,
100
+ new_session=new_session,
101
+ vconsole=vconsole,
102
+ )
103
+
104
+ graph = VoidXGraph(cfg, api_key, session=session, settings=settings)
105
+ await graph.run()
106
+
107
+
108
+ # ── default command (no subcommand needed) ──────────────────────────────
109
+
110
+ @cli.callback(invoke_without_command=True)
111
+ def main(
112
+ workspace: str = typer.Option(".", "-w", "--workspace", help="Working directory"),
113
+ model: str = typer.Option(None, "-m", "--model", help="Model name"),
114
+ provider: str = typer.Option(None, "-p", "--provider", help="Provider"),
115
+ resume: str = typer.Option(None, "-r", "--resume", help="Resume a session by ID"),
116
+ new: bool = typer.Option(False, "-n", "--new", help="Force new session"),
117
+ ) -> None:
118
+ """Start an interactive coding session."""
119
+ asyncio.run(_run_chat(workspace, model, provider, resume, new))
120
+
121
+
122
+ # ── subcommands ────────────────────────────────────────────────────────
123
+
124
+ @cli.command()
125
+ def sessions() -> None:
126
+ """List saved sessions."""
127
+ from voidx.memory.session import list_sessions
128
+ vconsole = _vconsole()
129
+
130
+ async def _run():
131
+ sessions = await list_sessions()
132
+ if not sessions:
133
+ vconsole.print("No saved sessions.")
134
+ return
135
+ vconsole.print("[bold]Sessions:[/bold]")
136
+ for s in sessions:
137
+ vconsole.print(
138
+ f" [cyan]{s.id}[/cyan] | {s.title[:60]} | "
139
+ f"{s.message_count} msgs | {s.updated_at[:16]}"
140
+ )
141
+
142
+ asyncio.run(_run())
143
+
144
+
145
+ @cli.command()
146
+ def version() -> None:
147
+ """Show version info."""
148
+ from voidx import __version__
149
+ vconsole = _vconsole()
150
+ vconsole.print(f"voidx v{__version__}")
151
+
152
+
153
+ if __name__ == "__main__":
154
+ cli()
voidx/mcp/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ """MCP (Model Context Protocol) support for voidx.
2
+
3
+ Provides:
4
+ - McpManager: lifecycle orchestrator for all MCP server connections
5
+ - McpClient: low-level JSON-RPC 2.0 client over stdio transport
6
+ - McpToolWrapper: adapts MCP tools into voidx's BaseTool interface
7
+ - McpRuntimeStatus: server status for UI display
8
+
9
+ Usage (automatic, via agent graph):
10
+ 1. Configure servers in voidx.json under "mcpServers"
11
+ 2. McpManager starts them on graph.run()
12
+ 3. Tools appear with id "mcp__{server}__{tool}_{hash}" in LLM function list
13
+ 4. Permission rules support "mcp__*" wildcard for blanket approval
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from voidx.mcp.manager import McpManager
19
+ from voidx.mcp.client import McpClient, McpConnectionError, McpProtocolError, McpTimeoutError
20
+ from voidx.mcp.tool import McpToolWrapper
21
+ from voidx.mcp.schema import McpRuntimeStatus, McpToolDef, McpCallResult
22
+
23
+ __all__ = [
24
+ "McpManager",
25
+ "McpClient",
26
+ "McpToolWrapper",
27
+ "McpRuntimeStatus",
28
+ "McpToolDef",
29
+ "McpCallResult",
30
+ "McpConnectionError",
31
+ "McpProtocolError",
32
+ "McpTimeoutError",
33
+ ]
voidx/mcp/client.py ADDED
@@ -0,0 +1,458 @@
1
+ """MCP client — stdio transport, JSON-RPC 2.0, crash-resilient.
2
+
3
+ Spawning pattern:
4
+ asyncio.create_subprocess_exec(command, *args)
5
+ stdin=PIPE, stdout=PIPE, stderr=PIPE
6
+
7
+ Wire format:
8
+ Line-delimited JSON (one JSON object per line, \\n terminated).
9
+ Requests and responses interleaved via JSON-RPC 2.0 id matching.
10
+
11
+ Lifecycle:
12
+ created → start() → initialized → list_tools() → call_tool()* → stop()
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import logging
20
+ import signal
21
+ from dataclasses import dataclass, field
22
+ from typing import Any
23
+
24
+ from voidx.config import McpServerConfig
25
+ from voidx.mcp.schema import (
26
+ JsonRpcRequest,
27
+ JsonRpcResponse,
28
+ JsonRpcNotification,
29
+ McpCallResult,
30
+ McpInitializeParams,
31
+ McpToolDef,
32
+ MCP_PROTOCOL_VERSION,
33
+ )
34
+
35
+ log = logging.getLogger(__name__)
36
+
37
+ # ── error types ───────────────────────────────────────────────────────────
38
+
39
+
40
+ class McpConnectionError(Exception):
41
+ """Connection-level error (process died, transport failure)."""
42
+
43
+
44
+ class McpProtocolError(Exception):
45
+ """Protocol-level error (invalid JSON, unexpected response)."""
46
+
47
+
48
+ class McpTimeoutError(Exception):
49
+ """Operation timed out."""
50
+
51
+
52
+ # ── pending request tracker ──────────────────────────────────────────────
53
+
54
+
55
+ @dataclass
56
+ class _PendingRequest:
57
+ future: asyncio.Future
58
+ method: str = ""
59
+
60
+
61
+ # ── client ────────────────────────────────────────────────────────────────
62
+
63
+
64
+ class McpClient:
65
+ """A single MCP server connection over stdio transport.
66
+
67
+ Thread safety: not intended for concurrent access. The agent graph
68
+ serializes tool calls within a single async context.
69
+ """
70
+
71
+ MAX_RECONNECT_ATTEMPTS = 3
72
+ INIT_TIMEOUT = 45.0
73
+ TOOL_CALL_TIMEOUT = 120.0
74
+ LIST_TOOLS_TIMEOUT = 30.0
75
+
76
+ def __init__(self, config: McpServerConfig) -> None:
77
+ self._config = config
78
+ self._proc: asyncio.subprocess.Process | None = None
79
+ self._reader: asyncio.StreamReader | None = None
80
+ self._writer: asyncio.StreamWriter | None = None
81
+ self._stderr_task: asyncio.Task | None = None
82
+ self._read_task: asyncio.Task | None = None
83
+
84
+ self._request_id = 0
85
+ self._pending: dict[int, _PendingRequest] = {}
86
+ self._initialized = False
87
+ self._healthy = False
88
+ self._error_message = ""
89
+ self._reconnect_attempt = 0
90
+ self._closed = False
91
+ self._server_name = config.name
92
+
93
+ # Synchronisation: only one call at a time per client
94
+ self._lock = asyncio.Lock()
95
+
96
+ # ── properties ──────────────────────────────────────────────────────
97
+
98
+ @property
99
+ def server_name(self) -> str:
100
+ return self._server_name
101
+
102
+ @property
103
+ def healthy(self) -> bool:
104
+ return self._healthy and self._proc is not None and self._proc.returncode is None
105
+
106
+ @property
107
+ def status(self) -> str:
108
+ if self._healthy:
109
+ return "connected"
110
+ if self._error_message:
111
+ return "error"
112
+ return "disconnected"
113
+
114
+ @property
115
+ def error_message(self) -> str:
116
+ return self._error_message
117
+
118
+ # ── lifecycle ───────────────────────────────────────────────────────
119
+
120
+ async def start(self) -> None:
121
+ """Spawn the subprocess, perform handshake, mark healthy."""
122
+ if self._healthy:
123
+ return
124
+ try:
125
+ await self._spawn()
126
+ await asyncio.wait_for(self._handshake(), timeout=self.INIT_TIMEOUT)
127
+ self._initialized = True
128
+ self._healthy = True
129
+ self._reconnect_attempt = 0
130
+ self._error_message = ""
131
+ log.info("MCP client '%s' connected", self._server_name)
132
+ except Exception as e:
133
+ await self._cleanup()
134
+ self._error_message = str(e)
135
+ raise McpConnectionError(f"Failed to initialize MCP server '{self._server_name}': {e}")
136
+
137
+ async def stop(self) -> None:
138
+ """Graceful shutdown. Sends shutdown notification then kills."""
139
+ if self._closed:
140
+ return
141
+ self._closed = True
142
+ self._healthy = False
143
+ if self._writer and self._proc and self._proc.returncode is None:
144
+ try:
145
+ notif = JsonRpcNotification(method="shutdown")
146
+ line = json.dumps(notif.to_dict(), ensure_ascii=False) + "\n"
147
+ self._writer.write(line.encode("utf-8"))
148
+ await asyncio.wait_for(self._writer.drain(), timeout=5.0)
149
+ except Exception:
150
+ pass
151
+ await self._cleanup()
152
+ log.info("MCP client '%s' stopped", self._server_name)
153
+
154
+ async def reconnect(self) -> bool:
155
+ """Attempt to reconnect a failed server."""
156
+ self._healthy = False
157
+ self._reconnect_attempt += 1
158
+ if self._reconnect_attempt > self.MAX_RECONNECT_ATTEMPTS:
159
+ self._error_message = f"Reconnect failed after {self.MAX_RECONNECT_ATTEMPTS} attempts"
160
+ return False
161
+ await self._cleanup()
162
+ try:
163
+ await self.start()
164
+ return True
165
+ except McpConnectionError:
166
+ return False
167
+
168
+ # ── protocol operations ──────────────────────────────────────────────
169
+
170
+ async def list_tools(self) -> list[McpToolDef]:
171
+ """Discover tools from the MCP server."""
172
+ resp = await self._request("tools/list", timeout=self.LIST_TOOLS_TIMEOUT)
173
+ result = resp.result
174
+ if not isinstance(result, dict):
175
+ raise McpProtocolError(f"Expected dict from tools/list, got {type(result).__name__}")
176
+ tools_data = result.get("tools", [])
177
+ if not isinstance(tools_data, list):
178
+ raise McpProtocolError(f"Expected list of tools, got {type(tools_data).__name__}")
179
+ return [
180
+ McpToolDef(
181
+ name=t.get("name", ""),
182
+ description=t.get("description", ""),
183
+ inputSchema=t.get("inputSchema", {}),
184
+ )
185
+ for t in tools_data
186
+ if isinstance(t, dict) and t.get("name")
187
+ ]
188
+
189
+ async def call_tool(self, name: str, arguments: dict[str, Any], timeout: float | None = None) -> McpCallResult:
190
+ """Call a tool on the MCP server."""
191
+ timeout = timeout or self.TOOL_CALL_TIMEOUT
192
+ params: dict[str, Any] = {"name": name}
193
+ if arguments:
194
+ params["arguments"] = arguments
195
+ resp = await self._request("tools/call", params, timeout=timeout)
196
+ result = resp.result
197
+ if not isinstance(result, dict):
198
+ raise McpProtocolError(f"Expected dict from tools/call, got {type(result).__name__}")
199
+ content = result.get("content", [])
200
+ if not isinstance(content, list):
201
+ content = []
202
+ is_error = bool(result.get("isError", False))
203
+ return McpCallResult(
204
+ content=content,
205
+ isError=is_error,
206
+ structured_content=result.get("structuredContent"),
207
+ )
208
+
209
+ # ── internal: transport ─────────────────────────────────────────────
210
+
211
+ async def _spawn(self) -> None:
212
+ """Spawn the subprocess with configured command/args/env."""
213
+ cmd = self._config.command
214
+ if not cmd:
215
+ raise McpConnectionError(f"MCP server '{self._server_name}' has no command configured")
216
+ args = [cmd] + list(self._config.args)
217
+
218
+ env = None
219
+ if self._config.env:
220
+ import os
221
+ env = {**os.environ, **self._config.env}
222
+
223
+ try:
224
+ self._proc = await asyncio.create_subprocess_exec(
225
+ *args,
226
+ stdin=asyncio.subprocess.PIPE,
227
+ stdout=asyncio.subprocess.PIPE,
228
+ stderr=asyncio.subprocess.PIPE,
229
+ env=env,
230
+ )
231
+ except FileNotFoundError as e:
232
+ raise McpConnectionError(
233
+ f"Command not found for MCP server '{self._server_name}': {cmd}"
234
+ ) from e
235
+ except PermissionError as e:
236
+ raise McpConnectionError(
237
+ f"Permission denied for MCP server '{self._server_name}': {cmd}"
238
+ ) from e
239
+
240
+ if self._proc.stdin is None or self._proc.stdout is None or self._proc.stderr is None:
241
+ await self._cleanup()
242
+ raise McpConnectionError(f"Failed to open pipes for MCP server '{self._server_name}'")
243
+
244
+ self._writer = self._proc.stdin
245
+ self._reader = self._proc.stdout
246
+
247
+ # Read stderr in background (logging only)
248
+ self._stderr_task = asyncio.create_task(self._read_stderr())
249
+
250
+ # Start background reader for incoming JSON-RPC responses
251
+ self._read_task = asyncio.create_task(self._read_responses())
252
+
253
+ async def _cleanup(self) -> None:
254
+ """Clean up subprocess and tasks."""
255
+ # Cancel background tasks
256
+ for task_name in ("_read_task", "_stderr_task"):
257
+ task = getattr(self, task_name, None)
258
+ if task and not task.done():
259
+ task.cancel()
260
+ try:
261
+ await task
262
+ except (asyncio.CancelledError, Exception):
263
+ pass
264
+ setattr(self, task_name, None)
265
+
266
+ self._reader = None
267
+ self._writer = None
268
+
269
+ # Reject all pending requests
270
+ for req in self._pending.values():
271
+ if not req.future.done():
272
+ req.future.set_exception(McpConnectionError("Connection closed"))
273
+ self._pending.clear()
274
+
275
+ # Terminate subprocess
276
+ proc = self._proc
277
+ self._proc = None
278
+ if proc and proc.returncode is None:
279
+ try:
280
+ proc.send_signal(signal.SIGTERM)
281
+ try:
282
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
283
+ except asyncio.TimeoutError:
284
+ proc.kill()
285
+ await proc.wait()
286
+ except ProcessLookupError:
287
+ pass
288
+
289
+ async def _read_stderr(self) -> None:
290
+ """Read stderr and forward to debug output."""
291
+ proc = self._proc
292
+ if proc is None or proc.stderr is None:
293
+ return
294
+ try:
295
+ while True:
296
+ line = await proc.stderr.readline()
297
+ if not line:
298
+ break
299
+ text = line.decode("utf-8", errors="replace").rstrip()
300
+ if text:
301
+ log.debug("[MCP stderr:%s] %s", self._server_name, text)
302
+ except Exception:
303
+ pass
304
+
305
+ # ── internal: protocol ──────────────────────────────────────────────
306
+
307
+ async def _handshake(self) -> None:
308
+ """Perform MCP initialization handshake."""
309
+ params = McpInitializeParams()
310
+ resp = await self._request("initialize", params.to_dict(), timeout=self.INIT_TIMEOUT)
311
+ result = resp.result
312
+ if not isinstance(result, dict):
313
+ raise McpProtocolError(
314
+ f"Expected dict from initialize, got {type(result).__name__}"
315
+ )
316
+ server_version = result.get("protocolVersion", "unknown")
317
+ if server_version != MCP_PROTOCOL_VERSION:
318
+ log.warning(
319
+ "MCP server '%s' protocol v%s, expected v%s",
320
+ self._server_name, server_version, MCP_PROTOCOL_VERSION,
321
+ )
322
+
323
+ # Send initialized notification (no response expected)
324
+ notif = JsonRpcNotification(method="notifications/initialized")
325
+ await self._send_notification(notif)
326
+
327
+ async def _request(self, method: str, params: dict[str, Any] | None = None, timeout: float = 30.0) -> JsonRpcResponse:
328
+ """Send a JSON-RPC request and wait for the matching response."""
329
+ if method != "initialize" and not self._healthy and self._reconnect_attempt < self.MAX_RECONNECT_ATTEMPTS:
330
+ if await self.reconnect():
331
+ pass
332
+ else:
333
+ raise McpConnectionError(self._error_message or "Not connected")
334
+ elif method != "initialize" and not self._healthy:
335
+ raise McpConnectionError(self._error_message or "Not connected")
336
+
337
+ async with self._lock:
338
+ req_id = self._next_id()
339
+ future: asyncio.Future[JsonRpcResponse] = asyncio.Future()
340
+ self._pending[req_id] = _PendingRequest(future=future, method=method)
341
+
342
+ request = JsonRpcRequest(
343
+ id=req_id,
344
+ method=method,
345
+ params=params or {},
346
+ )
347
+ try:
348
+ await self._send_request(request)
349
+ response = await asyncio.wait_for(future, timeout=timeout)
350
+ return response
351
+ except asyncio.TimeoutError:
352
+ self._pending.pop(req_id, None)
353
+ raise McpTimeoutError(
354
+ f"MCP server '{self._server_name}' did not respond to '{method}' within {timeout}s"
355
+ )
356
+ except (McpConnectionError, McpProtocolError):
357
+ self._pending.pop(req_id, None)
358
+ raise
359
+ except Exception as e:
360
+ self._pending.pop(req_id, None)
361
+ raise McpConnectionError(f"Unexpected error in MCP request '{method}': {e}")
362
+
363
+ async def _send_request(self, request: JsonRpcRequest) -> None:
364
+ """Send a JSON-RPC request as a single line."""
365
+ if self._writer is None:
366
+ raise McpConnectionError("No transport writer available")
367
+ line = json.dumps(request.to_dict(), ensure_ascii=False) + "\n"
368
+ try:
369
+ self._writer.write(line.encode("utf-8"))
370
+ await self._writer.drain()
371
+ except (BrokenPipeError, ConnectionResetError, OSError) as e:
372
+ self._healthy = False
373
+ raise McpConnectionError(f"Transport write error: {e}") from e
374
+
375
+ async def _send_notification(self, notification: JsonRpcNotification) -> None:
376
+ """Send a JSON-RPC notification (fire-and-forget)."""
377
+ if self._writer is None:
378
+ return
379
+ line = json.dumps(notification.to_dict(), ensure_ascii=False) + "\n"
380
+ try:
381
+ self._writer.write(line.encode("utf-8"))
382
+ await self._writer.drain()
383
+ except (BrokenPipeError, ConnectionResetError, OSError):
384
+ self._healthy = False
385
+
386
+ async def _read_responses(self) -> None:
387
+ """Background reader: parse inbound JSON-RPC messages and resolve pending futures."""
388
+ if self._reader is None:
389
+ return
390
+ buffer = ""
391
+ try:
392
+ while True:
393
+ chunk = await self._reader.read(65536)
394
+ if not chunk:
395
+ # EOF — process exited
396
+ self._healthy = False
397
+ self._error_message = "Process exited unexpectedly"
398
+ # Fail all pending
399
+ for req in self._pending.values():
400
+ if not req.future.done():
401
+ req.future.set_exception(
402
+ McpConnectionError("Process exited mid-request")
403
+ )
404
+ self._pending.clear()
405
+ break
406
+ buffer += chunk.decode("utf-8", errors="replace")
407
+ lines = buffer.split("\n")
408
+ buffer = lines[-1] # partial last line
409
+ for line in lines[:-1]:
410
+ line = line.strip()
411
+ if not line:
412
+ continue
413
+ try:
414
+ self._dispatch_line(line)
415
+ except Exception:
416
+ log.exception("MCP response dispatch error")
417
+ except asyncio.CancelledError:
418
+ pass
419
+ except Exception:
420
+ log.exception("MCP reader error")
421
+
422
+ def _dispatch_line(self, line: str) -> None:
423
+ """Parse a JSON line and route to pending request or drop (notification)."""
424
+ data = json.loads(line)
425
+ if not isinstance(data, dict):
426
+ return
427
+
428
+ msg_id = data.get("id")
429
+ if msg_id is not None:
430
+ # It's a response — resolve pending request
431
+ pending = self._pending.pop(int(msg_id), None)
432
+ if pending is None:
433
+ log.warning("Unexpected response id=%s for server '%s'", msg_id, self._server_name)
434
+ return
435
+ if not pending.future.done():
436
+ if "error" in data and data["error"] is not None:
437
+ err = data["error"]
438
+ err_msg = err.get("message", "Unknown error")
439
+ err_code = err.get("code", -1)
440
+ pending.future.set_exception(
441
+ McpProtocolError(f"MCP error [{err_code}]: {err_msg}")
442
+ )
443
+ else:
444
+ pending.future.set_result(JsonRpcResponse(
445
+ id=int(msg_id),
446
+ result=data.get("result"),
447
+ ))
448
+
449
+ def _next_id(self) -> int:
450
+ self._request_id += 1
451
+ return self._request_id
452
+
453
+ async def __aenter__(self) -> McpClient:
454
+ await self.start()
455
+ return self
456
+
457
+ async def __aexit__(self, *args: object) -> None:
458
+ await self.stop()