axion-code 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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""MCP transport configuration types.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/mcp_client.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class McpTransportType(enum.Enum):
|
|
14
|
+
STDIO = "stdio"
|
|
15
|
+
SSE = "sse"
|
|
16
|
+
HTTP = "http"
|
|
17
|
+
WEBSOCKET = "websocket"
|
|
18
|
+
SDK = "sdk"
|
|
19
|
+
MANAGED_PROXY = "managed_proxy"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class McpStdioTransport:
|
|
24
|
+
command: str
|
|
25
|
+
args: list[str] = field(default_factory=list)
|
|
26
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
27
|
+
tool_call_timeout_ms: int | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class McpRemoteTransport:
|
|
32
|
+
url: str
|
|
33
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
auth: McpClientAuth | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class McpSdkTransport:
|
|
39
|
+
name: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class McpManagedProxyTransport:
|
|
44
|
+
url: str
|
|
45
|
+
id: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Union type for all transport configurations
|
|
49
|
+
McpClientTransport = (
|
|
50
|
+
McpStdioTransport
|
|
51
|
+
| McpRemoteTransport
|
|
52
|
+
| McpSdkTransport
|
|
53
|
+
| McpManagedProxyTransport
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class McpClientAuthType(enum.Enum):
|
|
58
|
+
NONE = "none"
|
|
59
|
+
OAUTH = "oauth"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class McpClientAuth:
|
|
64
|
+
auth_type: McpClientAuthType = McpClientAuthType.NONE
|
|
65
|
+
oauth_config: dict[str, Any] | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class McpClientBootstrap:
|
|
70
|
+
"""Bootstrap configuration for an MCP client connection."""
|
|
71
|
+
|
|
72
|
+
server_name: str
|
|
73
|
+
normalized_name: str
|
|
74
|
+
tool_prefix: str
|
|
75
|
+
transport: McpClientTransport
|
|
76
|
+
signature: str = ""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Hardened MCP lifecycle management.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/mcp_lifecycle_hardened.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from axion.runtime.mcp.tool_bridge import McpConnectionStatus, McpServerState, McpToolRegistry
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class McpLifecycleEvent:
|
|
19
|
+
server_name: str
|
|
20
|
+
event_type: str
|
|
21
|
+
message: str = ""
|
|
22
|
+
error: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class McpLifecycleManager:
|
|
26
|
+
"""Manages MCP server lifecycle with error handling and degradation.
|
|
27
|
+
|
|
28
|
+
Maps to: rust/crates/runtime/src/mcp_lifecycle_hardened.rs
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, registry: McpToolRegistry | None = None) -> None:
|
|
32
|
+
self.registry = registry or McpToolRegistry()
|
|
33
|
+
self._events: list[McpLifecycleEvent] = []
|
|
34
|
+
|
|
35
|
+
def record_event(self, event: McpLifecycleEvent) -> None:
|
|
36
|
+
self._events.append(event)
|
|
37
|
+
if event.error:
|
|
38
|
+
logger.warning(
|
|
39
|
+
"MCP lifecycle event [%s] %s: %s",
|
|
40
|
+
event.server_name,
|
|
41
|
+
event.event_type,
|
|
42
|
+
event.error,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def mark_server_connected(
|
|
46
|
+
self, server_name: str, tools: list[Any] | None = None
|
|
47
|
+
) -> None:
|
|
48
|
+
from axion.runtime.mcp.tool_bridge import McpToolInfo
|
|
49
|
+
|
|
50
|
+
tool_infos = []
|
|
51
|
+
if tools:
|
|
52
|
+
for t in tools:
|
|
53
|
+
if isinstance(t, McpToolInfo):
|
|
54
|
+
tool_infos.append(t)
|
|
55
|
+
elif isinstance(t, dict):
|
|
56
|
+
tool_infos.append(McpToolInfo(
|
|
57
|
+
name=t.get("name", ""),
|
|
58
|
+
description=t.get("description", ""),
|
|
59
|
+
input_schema=t.get("inputSchema", {}),
|
|
60
|
+
))
|
|
61
|
+
|
|
62
|
+
state = McpServerState(
|
|
63
|
+
server_name=server_name,
|
|
64
|
+
status=McpConnectionStatus.CONNECTED,
|
|
65
|
+
tools=tool_infos,
|
|
66
|
+
)
|
|
67
|
+
self.registry.register_server(state)
|
|
68
|
+
self.record_event(McpLifecycleEvent(
|
|
69
|
+
server_name=server_name,
|
|
70
|
+
event_type="connected",
|
|
71
|
+
message=f"Connected with {len(tool_infos)} tools",
|
|
72
|
+
))
|
|
73
|
+
|
|
74
|
+
def mark_server_error(self, server_name: str, error: str) -> None:
|
|
75
|
+
state = McpServerState(
|
|
76
|
+
server_name=server_name,
|
|
77
|
+
status=McpConnectionStatus.ERROR,
|
|
78
|
+
error_message=error,
|
|
79
|
+
)
|
|
80
|
+
self.registry.register_server(state)
|
|
81
|
+
self.record_event(McpLifecycleEvent(
|
|
82
|
+
server_name=server_name,
|
|
83
|
+
event_type="error",
|
|
84
|
+
error=error,
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
def mark_server_disconnected(self, server_name: str) -> None:
|
|
88
|
+
state = McpServerState(
|
|
89
|
+
server_name=server_name,
|
|
90
|
+
status=McpConnectionStatus.DISCONNECTED,
|
|
91
|
+
)
|
|
92
|
+
self.registry.register_server(state)
|
|
93
|
+
self.record_event(McpLifecycleEvent(
|
|
94
|
+
server_name=server_name,
|
|
95
|
+
event_type="disconnected",
|
|
96
|
+
))
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) server manager with JSON-RPC over stdio.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/mcp_stdio.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
MCP_PROTOCOL_VERSION = "2024-11-05"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# JSON-RPC types
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class JsonRpcRequest:
|
|
25
|
+
method: str
|
|
26
|
+
params: dict[str, Any] | None = None
|
|
27
|
+
id: str | int | None = None
|
|
28
|
+
jsonrpc: str = "2.0"
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
d: dict[str, Any] = {"jsonrpc": self.jsonrpc, "method": self.method}
|
|
32
|
+
if self.id is not None:
|
|
33
|
+
d["id"] = self.id
|
|
34
|
+
if self.params is not None:
|
|
35
|
+
d["params"] = self.params
|
|
36
|
+
return d
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class JsonRpcError:
|
|
41
|
+
code: int
|
|
42
|
+
message: str
|
|
43
|
+
data: Any = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class JsonRpcResponse:
|
|
48
|
+
id: str | int | None = None
|
|
49
|
+
result: Any = None
|
|
50
|
+
error: JsonRpcError | None = None
|
|
51
|
+
jsonrpc: str = "2.0"
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_dict(cls, data: dict[str, Any]) -> JsonRpcResponse:
|
|
55
|
+
error = None
|
|
56
|
+
if "error" in data:
|
|
57
|
+
e = data["error"]
|
|
58
|
+
error = JsonRpcError(code=e["code"], message=e["message"], data=e.get("data"))
|
|
59
|
+
return cls(
|
|
60
|
+
id=data.get("id"),
|
|
61
|
+
result=data.get("result"),
|
|
62
|
+
error=error,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# MCP types
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class McpTool:
|
|
72
|
+
name: str
|
|
73
|
+
description: str = ""
|
|
74
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class McpResource:
|
|
79
|
+
uri: str
|
|
80
|
+
name: str
|
|
81
|
+
description: str = ""
|
|
82
|
+
mime_type: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class McpToolCallResult:
|
|
87
|
+
content: list[dict[str, Any]] = field(default_factory=list)
|
|
88
|
+
is_error: bool = False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
# MCP Server Connection
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
class McpServerConnection:
|
|
96
|
+
"""Manages a single MCP server connection over stdio."""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
name: str,
|
|
101
|
+
command: str,
|
|
102
|
+
args: list[str] | None = None,
|
|
103
|
+
env: dict[str, str] | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
self.name = name
|
|
106
|
+
self.command = command
|
|
107
|
+
self.args = args or []
|
|
108
|
+
self.env = env
|
|
109
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
110
|
+
self._reader: asyncio.StreamReader | None = None
|
|
111
|
+
self._writer: asyncio.StreamWriter | None = None
|
|
112
|
+
self._request_id = 0
|
|
113
|
+
self._pending: dict[int, asyncio.Future[JsonRpcResponse]] = {}
|
|
114
|
+
self._tools: list[McpTool] = []
|
|
115
|
+
self._resources: list[McpResource] = []
|
|
116
|
+
self._read_task: asyncio.Task[None] | None = None
|
|
117
|
+
|
|
118
|
+
async def connect(self) -> None:
|
|
119
|
+
"""Start the MCP server process and initialize."""
|
|
120
|
+
import os
|
|
121
|
+
|
|
122
|
+
env = {**os.environ, **(self.env or {})}
|
|
123
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
124
|
+
self.command,
|
|
125
|
+
*self.args,
|
|
126
|
+
stdin=asyncio.subprocess.PIPE,
|
|
127
|
+
stdout=asyncio.subprocess.PIPE,
|
|
128
|
+
stderr=asyncio.subprocess.PIPE,
|
|
129
|
+
env=env,
|
|
130
|
+
)
|
|
131
|
+
self._reader = self._process.stdout
|
|
132
|
+
self._writer = self._process.stdin
|
|
133
|
+
|
|
134
|
+
# Start reading responses
|
|
135
|
+
self._read_task = asyncio.create_task(self._read_loop())
|
|
136
|
+
|
|
137
|
+
# Initialize
|
|
138
|
+
_init_result = await self._send_request("initialize", {
|
|
139
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
140
|
+
"capabilities": {},
|
|
141
|
+
"clientInfo": {"name": "axion-code", "version": "1.0.0"},
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
# Send initialized notification
|
|
145
|
+
await self._send_notification("notifications/initialized", {})
|
|
146
|
+
|
|
147
|
+
# List tools
|
|
148
|
+
tools_result = await self._send_request("tools/list", {})
|
|
149
|
+
if tools_result and tools_result.result:
|
|
150
|
+
for t in tools_result.result.get("tools", []):
|
|
151
|
+
self._tools.append(McpTool(
|
|
152
|
+
name=t["name"],
|
|
153
|
+
description=t.get("description", ""),
|
|
154
|
+
input_schema=t.get("inputSchema", {}),
|
|
155
|
+
))
|
|
156
|
+
|
|
157
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]) -> McpToolCallResult:
|
|
158
|
+
"""Call a tool on the MCP server."""
|
|
159
|
+
result = await self._send_request("tools/call", {
|
|
160
|
+
"name": name,
|
|
161
|
+
"arguments": arguments,
|
|
162
|
+
})
|
|
163
|
+
if result and result.error:
|
|
164
|
+
return McpToolCallResult(
|
|
165
|
+
content=[{"type": "text", "text": result.error.message}],
|
|
166
|
+
is_error=True,
|
|
167
|
+
)
|
|
168
|
+
if result and result.result:
|
|
169
|
+
return McpToolCallResult(
|
|
170
|
+
content=result.result.get("content", []),
|
|
171
|
+
is_error=result.result.get("isError", False),
|
|
172
|
+
)
|
|
173
|
+
return McpToolCallResult(is_error=True)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def tools(self) -> list[McpTool]:
|
|
177
|
+
return self._tools
|
|
178
|
+
|
|
179
|
+
@property
|
|
180
|
+
def resources(self) -> list[McpResource]:
|
|
181
|
+
return self._resources
|
|
182
|
+
|
|
183
|
+
async def disconnect(self) -> None:
|
|
184
|
+
"""Shutdown the MCP server."""
|
|
185
|
+
if self._read_task:
|
|
186
|
+
self._read_task.cancel()
|
|
187
|
+
if self._process:
|
|
188
|
+
try:
|
|
189
|
+
self._process.terminate()
|
|
190
|
+
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
|
191
|
+
except (asyncio.TimeoutError, ProcessLookupError):
|
|
192
|
+
self._process.kill()
|
|
193
|
+
|
|
194
|
+
def _next_id(self) -> int:
|
|
195
|
+
self._request_id += 1
|
|
196
|
+
return self._request_id
|
|
197
|
+
|
|
198
|
+
async def _send_request(
|
|
199
|
+
self, method: str, params: dict[str, Any]
|
|
200
|
+
) -> JsonRpcResponse | None:
|
|
201
|
+
req_id = self._next_id()
|
|
202
|
+
request = JsonRpcRequest(method=method, params=params, id=req_id)
|
|
203
|
+
|
|
204
|
+
future: asyncio.Future[JsonRpcResponse] = asyncio.get_event_loop().create_future()
|
|
205
|
+
self._pending[req_id] = future
|
|
206
|
+
|
|
207
|
+
await self._write_message(request.to_dict())
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
return await asyncio.wait_for(future, timeout=30.0)
|
|
211
|
+
except asyncio.TimeoutError:
|
|
212
|
+
self._pending.pop(req_id, None)
|
|
213
|
+
logger.error("MCP request timed out: %s", method)
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
|
|
217
|
+
request = JsonRpcRequest(method=method, params=params)
|
|
218
|
+
await self._write_message(request.to_dict())
|
|
219
|
+
|
|
220
|
+
async def _write_message(self, data: dict[str, Any]) -> None:
|
|
221
|
+
if self._writer is None:
|
|
222
|
+
return
|
|
223
|
+
content = json.dumps(data)
|
|
224
|
+
message = f"Content-Length: {len(content)}\r\n\r\n{content}"
|
|
225
|
+
self._writer.write(message.encode())
|
|
226
|
+
await self._writer.drain()
|
|
227
|
+
|
|
228
|
+
async def _read_loop(self) -> None:
|
|
229
|
+
"""Read JSON-RPC responses from the server."""
|
|
230
|
+
if self._reader is None:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
buffer = b""
|
|
234
|
+
while True:
|
|
235
|
+
try:
|
|
236
|
+
chunk = await self._reader.read(4096)
|
|
237
|
+
if not chunk:
|
|
238
|
+
break
|
|
239
|
+
buffer += chunk
|
|
240
|
+
|
|
241
|
+
# Parse Content-Length header
|
|
242
|
+
while b"\r\n\r\n" in buffer:
|
|
243
|
+
header_end = buffer.index(b"\r\n\r\n")
|
|
244
|
+
header = buffer[:header_end].decode()
|
|
245
|
+
content_length = 0
|
|
246
|
+
for line in header.split("\r\n"):
|
|
247
|
+
if line.lower().startswith("content-length:"):
|
|
248
|
+
content_length = int(line.split(":")[1].strip())
|
|
249
|
+
|
|
250
|
+
body_start = header_end + 4
|
|
251
|
+
if len(buffer) < body_start + content_length:
|
|
252
|
+
break # Not enough data yet
|
|
253
|
+
|
|
254
|
+
body = buffer[body_start:body_start + content_length].decode()
|
|
255
|
+
buffer = buffer[body_start + content_length:]
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
data = json.loads(body)
|
|
259
|
+
response = JsonRpcResponse.from_dict(data)
|
|
260
|
+
if response.id is not None and response.id in self._pending:
|
|
261
|
+
future = self._pending.pop(response.id)
|
|
262
|
+
if not future.done():
|
|
263
|
+
future.set_result(response)
|
|
264
|
+
except json.JSONDecodeError:
|
|
265
|
+
logger.warning("Invalid JSON from MCP server")
|
|
266
|
+
|
|
267
|
+
except asyncio.CancelledError:
|
|
268
|
+
break
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
logger.error("MCP read error: %s", exc)
|
|
271
|
+
break
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
# MCP Server Manager
|
|
276
|
+
# ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
class McpServerManager:
|
|
279
|
+
"""Manages multiple MCP server connections.
|
|
280
|
+
|
|
281
|
+
Maps to: rust/crates/runtime/src/mcp_stdio.rs::McpServerManager
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
def __init__(self) -> None:
|
|
285
|
+
self._servers: dict[str, McpServerConnection] = {}
|
|
286
|
+
|
|
287
|
+
async def connect(
|
|
288
|
+
self,
|
|
289
|
+
name: str,
|
|
290
|
+
command: str,
|
|
291
|
+
args: list[str] | None = None,
|
|
292
|
+
env: dict[str, str] | None = None,
|
|
293
|
+
) -> McpServerConnection:
|
|
294
|
+
"""Connect to an MCP server."""
|
|
295
|
+
conn = McpServerConnection(name=name, command=command, args=args, env=env)
|
|
296
|
+
await conn.connect()
|
|
297
|
+
self._servers[name] = conn
|
|
298
|
+
return conn
|
|
299
|
+
|
|
300
|
+
def get(self, name: str) -> McpServerConnection | None:
|
|
301
|
+
return self._servers.get(name)
|
|
302
|
+
|
|
303
|
+
def all_servers(self) -> list[McpServerConnection]:
|
|
304
|
+
return list(self._servers.values())
|
|
305
|
+
|
|
306
|
+
def all_tools(self) -> list[tuple[str, McpTool]]:
|
|
307
|
+
"""Get all tools from all connected servers."""
|
|
308
|
+
tools = []
|
|
309
|
+
for server in self._servers.values():
|
|
310
|
+
for tool in server.tools:
|
|
311
|
+
tools.append((server.name, tool))
|
|
312
|
+
return tools
|
|
313
|
+
|
|
314
|
+
async def disconnect_all(self) -> None:
|
|
315
|
+
"""Disconnect all servers."""
|
|
316
|
+
for server in self._servers.values():
|
|
317
|
+
await server.disconnect()
|
|
318
|
+
self._servers.clear()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""MCP tool registry bridge.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/mcp_tool_bridge.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class McpConnectionStatus(enum.Enum):
|
|
14
|
+
DISCONNECTED = "disconnected"
|
|
15
|
+
CONNECTING = "connecting"
|
|
16
|
+
CONNECTED = "connected"
|
|
17
|
+
AUTH_REQUIRED = "auth_required"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class McpToolInfo:
|
|
23
|
+
name: str
|
|
24
|
+
description: str = ""
|
|
25
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class McpResourceInfo:
|
|
30
|
+
uri: str
|
|
31
|
+
name: str
|
|
32
|
+
description: str = ""
|
|
33
|
+
mime_type: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class McpServerState:
|
|
38
|
+
"""State of a connected MCP server."""
|
|
39
|
+
|
|
40
|
+
server_name: str
|
|
41
|
+
status: McpConnectionStatus = McpConnectionStatus.DISCONNECTED
|
|
42
|
+
tools: list[McpToolInfo] = field(default_factory=list)
|
|
43
|
+
resources: list[McpResourceInfo] = field(default_factory=list)
|
|
44
|
+
error_message: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class McpToolRegistry:
|
|
48
|
+
"""Tracks connected MCP servers and their available tools.
|
|
49
|
+
|
|
50
|
+
Maps to: rust/crates/runtime/src/mcp_tool_bridge.rs::McpToolRegistry
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self._servers: dict[str, McpServerState] = {}
|
|
55
|
+
|
|
56
|
+
def register_server(self, state: McpServerState) -> None:
|
|
57
|
+
self._servers[state.server_name] = state
|
|
58
|
+
|
|
59
|
+
def get_server(self, name: str) -> McpServerState | None:
|
|
60
|
+
return self._servers.get(name)
|
|
61
|
+
|
|
62
|
+
def all_servers(self) -> list[McpServerState]:
|
|
63
|
+
return list(self._servers.values())
|
|
64
|
+
|
|
65
|
+
def all_tools(self) -> list[tuple[str, McpToolInfo]]:
|
|
66
|
+
"""Get all tools with their server name."""
|
|
67
|
+
tools = []
|
|
68
|
+
for state in self._servers.values():
|
|
69
|
+
if state.status == McpConnectionStatus.CONNECTED:
|
|
70
|
+
for tool in state.tools:
|
|
71
|
+
tools.append((state.server_name, tool))
|
|
72
|
+
return tools
|
|
73
|
+
|
|
74
|
+
def find_tool(self, tool_name: str) -> tuple[str, McpToolInfo] | None:
|
|
75
|
+
"""Find a tool by name across all servers."""
|
|
76
|
+
for server_name, tool in self.all_tools():
|
|
77
|
+
if tool.name == tool_name:
|
|
78
|
+
return (server_name, tool)
|
|
79
|
+
return None
|