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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- 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()
|