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/mcp/manager.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""MCP manager — lifecycle orchestrator for all MCP server connections.
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
- Reads server configs from Settings
|
|
5
|
+
- Spawns McpClient per server
|
|
6
|
+
- Discovers tools and registers them in ToolRegistry
|
|
7
|
+
- Exposes status for UI
|
|
8
|
+
- Clean shutdown on graph exit
|
|
9
|
+
|
|
10
|
+
Design: all servers start in parallel, a single failure doesn't block others.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import logging
|
|
17
|
+
|
|
18
|
+
from voidx.config import McpServerConfig, Settings
|
|
19
|
+
from voidx.mcp.client import McpClient, McpConnectionError
|
|
20
|
+
from voidx.mcp.schema import McpCallResult, McpRuntimeStatus, McpToolDef
|
|
21
|
+
from voidx.mcp.tool import McpToolWrapper, mcp_tool_id
|
|
22
|
+
from voidx.permission.service import PermissionService
|
|
23
|
+
from voidx.tools.registry import ToolRegistry
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class McpManager:
|
|
29
|
+
"""Manages all MCP server connections for a session."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, settings: Settings | None, registry: ToolRegistry, permission: PermissionService) -> None:
|
|
32
|
+
self._settings = settings
|
|
33
|
+
self._registry = registry
|
|
34
|
+
self._permission = permission
|
|
35
|
+
self._clients: dict[str, McpClient] = {}
|
|
36
|
+
self._started = False
|
|
37
|
+
self._tool_counts: dict[str, int] = {}
|
|
38
|
+
self._errors: dict[str, str] = {}
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def started(self) -> bool:
|
|
42
|
+
return self._started
|
|
43
|
+
|
|
44
|
+
# ── lifecycle ───────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
async def start_all(self) -> None:
|
|
47
|
+
"""Start all configured MCP servers and register their tools.
|
|
48
|
+
|
|
49
|
+
Servers start in parallel. A failed server doesn't block others.
|
|
50
|
+
"""
|
|
51
|
+
if self._started:
|
|
52
|
+
return
|
|
53
|
+
self._started = True
|
|
54
|
+
self._registry.unregister_prefix("mcp__")
|
|
55
|
+
self._tool_counts.clear()
|
|
56
|
+
|
|
57
|
+
if self._settings is None:
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
servers = self._settings.list_mcp_servers()
|
|
61
|
+
if not servers:
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
enabled = [s for s in servers if not s.disabled]
|
|
65
|
+
if not enabled:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
log.info("Starting %d MCP server(s)...", len(enabled))
|
|
69
|
+
|
|
70
|
+
# Start all servers concurrently
|
|
71
|
+
results = await self._start_servers(enabled)
|
|
72
|
+
|
|
73
|
+
# Register tools from successfully started servers
|
|
74
|
+
for server_name, client in results:
|
|
75
|
+
try:
|
|
76
|
+
tool_defs = await client.list_tools()
|
|
77
|
+
except Exception as e:
|
|
78
|
+
log.warning("Could not list tools from MCP server '%s': %s", server_name, e)
|
|
79
|
+
self._errors[server_name] = f"Could not list tools: {e}"
|
|
80
|
+
self._tool_counts[server_name] = 0
|
|
81
|
+
continue
|
|
82
|
+
self._errors.pop(server_name, None)
|
|
83
|
+
|
|
84
|
+
allowed = self._resolve_tool_filter(
|
|
85
|
+
next((s for s in servers if s.name == server_name), None)
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
registered = 0
|
|
89
|
+
for td in tool_defs:
|
|
90
|
+
if allowed is not None and td.name not in allowed:
|
|
91
|
+
continue
|
|
92
|
+
wrapper = McpToolWrapper(client, td, server_name)
|
|
93
|
+
self._registry.register(
|
|
94
|
+
wrapper.id,
|
|
95
|
+
wrapper,
|
|
96
|
+
wrapper.description,
|
|
97
|
+
wrapper.parameters_schema(),
|
|
98
|
+
)
|
|
99
|
+
registered += 1
|
|
100
|
+
|
|
101
|
+
# Pre-deny tools that are in deny filter (user explicitly wants them blocked)
|
|
102
|
+
disallowed = self._resolve_tool_filter(
|
|
103
|
+
next((s for s in servers if s.name == server_name), None),
|
|
104
|
+
allow_mode=False,
|
|
105
|
+
)
|
|
106
|
+
if disallowed:
|
|
107
|
+
for tool_name in disallowed:
|
|
108
|
+
tool_id = mcp_tool_id(server_name, tool_name)
|
|
109
|
+
self._permission.deny_silent(tool_id)
|
|
110
|
+
|
|
111
|
+
log.info(
|
|
112
|
+
"MCP server '%s': %d tools registered",
|
|
113
|
+
server_name, registered,
|
|
114
|
+
)
|
|
115
|
+
self._tool_counts[server_name] = registered
|
|
116
|
+
|
|
117
|
+
async def stop_all(self) -> None:
|
|
118
|
+
"""Gracefully stop all MCP server connections."""
|
|
119
|
+
self._started = False
|
|
120
|
+
self._registry.unregister_prefix("mcp__")
|
|
121
|
+
self._tool_counts.clear()
|
|
122
|
+
if not self._clients:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
log.info("Stopping %d MCP server(s)...", len(self._clients))
|
|
126
|
+
await self._stop_servers(list(self._clients.values()))
|
|
127
|
+
self._clients.clear()
|
|
128
|
+
|
|
129
|
+
async def reconnect(self, server_name: str) -> bool:
|
|
130
|
+
"""Attempt to reconnect a specific server."""
|
|
131
|
+
client = self._clients.get(server_name)
|
|
132
|
+
if client is None:
|
|
133
|
+
return False
|
|
134
|
+
return await client.reconnect()
|
|
135
|
+
|
|
136
|
+
async def restart_all(self) -> None:
|
|
137
|
+
"""Restart all configured server connections and re-register tools."""
|
|
138
|
+
await self.stop_all()
|
|
139
|
+
await self.start_all()
|
|
140
|
+
|
|
141
|
+
async def list_tools_for_server(self, server_name: str) -> list[McpToolDef]:
|
|
142
|
+
"""List tools from a connected server."""
|
|
143
|
+
client = self._clients.get(server_name)
|
|
144
|
+
if client is None:
|
|
145
|
+
raise McpConnectionError(f"MCP server '{server_name}' is not connected")
|
|
146
|
+
return await client.list_tools()
|
|
147
|
+
|
|
148
|
+
async def call_tool(
|
|
149
|
+
self,
|
|
150
|
+
server_name: str,
|
|
151
|
+
tool_name: str,
|
|
152
|
+
arguments: dict,
|
|
153
|
+
) -> McpCallResult:
|
|
154
|
+
"""Call a tool on a connected MCP server."""
|
|
155
|
+
client = self._clients.get(server_name)
|
|
156
|
+
if client is None:
|
|
157
|
+
raise McpConnectionError(f"MCP server '{server_name}' is not connected")
|
|
158
|
+
if not client.healthy:
|
|
159
|
+
ok = await client.reconnect()
|
|
160
|
+
if not ok:
|
|
161
|
+
raise McpConnectionError(
|
|
162
|
+
f"MCP server '{server_name}' is unavailable: {client.error_message}"
|
|
163
|
+
)
|
|
164
|
+
return await client.call_tool(tool_name, arguments)
|
|
165
|
+
|
|
166
|
+
def statuses(self) -> list[McpRuntimeStatus]:
|
|
167
|
+
"""Return runtime status of all configured servers (for UI)."""
|
|
168
|
+
if self._settings is None:
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
servers = self._settings.list_mcp_servers()
|
|
172
|
+
result: list[McpRuntimeStatus] = []
|
|
173
|
+
for sc in servers:
|
|
174
|
+
if sc.disabled:
|
|
175
|
+
result.append(McpRuntimeStatus(
|
|
176
|
+
name=sc.name,
|
|
177
|
+
status="disabled",
|
|
178
|
+
))
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
client = self._clients.get(sc.name)
|
|
182
|
+
if client is None:
|
|
183
|
+
error = self._errors.get(sc.name, "")
|
|
184
|
+
result.append(McpRuntimeStatus(
|
|
185
|
+
name=sc.name,
|
|
186
|
+
status="error" if error else "disconnected",
|
|
187
|
+
error_message=error,
|
|
188
|
+
))
|
|
189
|
+
else:
|
|
190
|
+
error = self._errors.get(sc.name, "")
|
|
191
|
+
result.append(McpRuntimeStatus(
|
|
192
|
+
name=sc.name,
|
|
193
|
+
status="error" if error else client.status,
|
|
194
|
+
tool_count=self._tool_counts.get(sc.name, 0),
|
|
195
|
+
error_message=error or client.error_message,
|
|
196
|
+
))
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
# ── internal ────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
async def _start_servers(self, configs: list[McpServerConfig]) -> list[tuple[str, McpClient]]:
|
|
202
|
+
"""Start multiple servers in parallel, collect successes."""
|
|
203
|
+
async def try_start(sc: McpServerConfig) -> tuple[str, McpClient] | None:
|
|
204
|
+
if sc.name in self._clients:
|
|
205
|
+
return sc.name, self._clients[sc.name]
|
|
206
|
+
client = McpClient(sc)
|
|
207
|
+
try:
|
|
208
|
+
await client.start()
|
|
209
|
+
self._clients[sc.name] = client
|
|
210
|
+
self._errors.pop(sc.name, None)
|
|
211
|
+
return sc.name, client
|
|
212
|
+
except McpConnectionError as e:
|
|
213
|
+
log.warning("MCP server '%s' failed to start: %s", sc.name, e)
|
|
214
|
+
self._errors[sc.name] = str(e)
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
tasks = [try_start(sc) for sc in configs]
|
|
218
|
+
results = await self._gather_safe(tasks)
|
|
219
|
+
|
|
220
|
+
successes: list[tuple[str, McpClient]] = []
|
|
221
|
+
for entry in results:
|
|
222
|
+
if entry is not None and not isinstance(entry, BaseException):
|
|
223
|
+
successes.append(entry)
|
|
224
|
+
return successes
|
|
225
|
+
|
|
226
|
+
async def _stop_servers(self, clients: list[McpClient]) -> None:
|
|
227
|
+
"""Stop multiple servers in parallel."""
|
|
228
|
+
async def safe_stop(client: McpClient) -> None:
|
|
229
|
+
try:
|
|
230
|
+
await client.stop()
|
|
231
|
+
except Exception:
|
|
232
|
+
log.exception("Error stopping MCP server '%s'", client.server_name)
|
|
233
|
+
|
|
234
|
+
await self._gather_safe([safe_stop(c) for c in clients])
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
async def _gather_safe(tasks: list) -> list:
|
|
238
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
239
|
+
return [None if isinstance(r, BaseException) else r for r in results]
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def _resolve_tool_filter(
|
|
243
|
+
server_config: McpServerConfig | None, allow_mode: bool = True,
|
|
244
|
+
) -> set[str] | None:
|
|
245
|
+
"""Resolve the tools filter from server config.
|
|
246
|
+
|
|
247
|
+
If allow_mode=True: returns the set of tool names that ARE allowed.
|
|
248
|
+
If allow_mode=False: returns the set of tool names that are DENIED.
|
|
249
|
+
"""
|
|
250
|
+
if server_config is None:
|
|
251
|
+
return None
|
|
252
|
+
tools_field = getattr(server_config, "tools", None)
|
|
253
|
+
if tools_field is None:
|
|
254
|
+
return None
|
|
255
|
+
if isinstance(tools_field, list):
|
|
256
|
+
# List of tool names that are allowed
|
|
257
|
+
if len(tools_field) == 0:
|
|
258
|
+
return set() if allow_mode else None
|
|
259
|
+
return set(tools_field) if allow_mode else None
|
|
260
|
+
if isinstance(tools_field, dict):
|
|
261
|
+
if allow_mode:
|
|
262
|
+
allowed = {k for k, v in tools_field.items() if v}
|
|
263
|
+
return allowed if allowed else None
|
|
264
|
+
else:
|
|
265
|
+
denied = {k for k, v in tools_field.items() if not v}
|
|
266
|
+
return denied if denied else None
|
|
267
|
+
return None
|
voidx/mcp/schema.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""MCP protocol types — JSON-RPC wire format, tool definitions, call results.
|
|
2
|
+
|
|
3
|
+
Based on the Model Context Protocol specification:
|
|
4
|
+
https://spec.modelcontextprotocol.io/
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── JSON-RPC 2.0 ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class JsonRpcRequest:
|
|
18
|
+
"""A JSON-RPC 2.0 request message."""
|
|
19
|
+
jsonrpc: str = "2.0"
|
|
20
|
+
id: int = 0
|
|
21
|
+
method: str = ""
|
|
22
|
+
params: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict[str, Any]:
|
|
25
|
+
d: dict[str, Any] = {
|
|
26
|
+
"jsonrpc": "2.0",
|
|
27
|
+
"id": self.id,
|
|
28
|
+
"method": self.method,
|
|
29
|
+
}
|
|
30
|
+
if self.params:
|
|
31
|
+
d["params"] = self.params
|
|
32
|
+
return d
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class JsonRpcResponse:
|
|
37
|
+
"""A JSON-RPC 2.0 response message."""
|
|
38
|
+
id: int = 0
|
|
39
|
+
result: Any = None
|
|
40
|
+
error: dict[str, Any] | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class JsonRpcNotification:
|
|
45
|
+
"""A JSON-RPC 2.0 notification (no id, no response expected)."""
|
|
46
|
+
jsonrpc: str = "2.0"
|
|
47
|
+
method: str = ""
|
|
48
|
+
params: dict[str, Any] | None = None
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
d: dict[str, Any] = {
|
|
52
|
+
"jsonrpc": "2.0",
|
|
53
|
+
"method": self.method,
|
|
54
|
+
}
|
|
55
|
+
if self.params:
|
|
56
|
+
d["params"] = self.params
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── MCP protocol messages ─────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
MCP_PROTOCOL_VERSION = "2025-03-26"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class McpInitializeParams:
|
|
68
|
+
"""Parameters for the initialize request."""
|
|
69
|
+
protocol_version: str = MCP_PROTOCOL_VERSION
|
|
70
|
+
capabilities: dict[str, Any] = field(default_factory=lambda: {
|
|
71
|
+
"roots": {"listChanged": False},
|
|
72
|
+
"sampling": {},
|
|
73
|
+
})
|
|
74
|
+
client_info: dict[str, str] = field(default_factory=lambda: {
|
|
75
|
+
"name": "voidx",
|
|
76
|
+
"version": "1.0.0",
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"protocolVersion": self.protocol_version,
|
|
82
|
+
"capabilities": self.capabilities,
|
|
83
|
+
"clientInfo": self.client_info,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class McpToolDef:
|
|
89
|
+
"""A tool exposed by an MCP server."""
|
|
90
|
+
name: str
|
|
91
|
+
description: str = ""
|
|
92
|
+
inputSchema: dict[str, Any] = field(default_factory=dict)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class McpCallResult:
|
|
97
|
+
"""Result from a tools/call request."""
|
|
98
|
+
content: list[dict[str, Any]] = field(default_factory=list)
|
|
99
|
+
isError: bool = False
|
|
100
|
+
structured_content: Any = None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── Server status (for UI consumption) ────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class McpRuntimeStatus:
|
|
108
|
+
"""Runtime status of a single MCP server connection."""
|
|
109
|
+
name: str
|
|
110
|
+
status: str # "connected" | "disconnected" | "error"
|
|
111
|
+
tool_count: int = 0
|
|
112
|
+
error_message: str = ""
|
voidx/mcp/tool.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""MCP tool wrapper — adapts MCP server tools into voidx's BaseTool interface.
|
|
2
|
+
|
|
3
|
+
Each discovered MCP tool becomes a McpToolWrapper registered in ToolRegistry
|
|
4
|
+
with an LLM-safe id derived from the server and tool name.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from voidx.mcp.client import McpClient, McpConnectionError, McpProtocolError, McpTimeoutError
|
|
15
|
+
from voidx.mcp.schema import McpCallResult, McpToolDef
|
|
16
|
+
from voidx.tools.base import BaseTool, ToolResult, ToolContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_LLM_TOOL_NAME_MAX = 64
|
|
20
|
+
_INVALID_TOOL_NAME_CHARS = re.compile(r"[^A-Za-z0-9_-]+")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mcp_tool_id(server_name: str, tool_name: str) -> str:
|
|
24
|
+
raw = f"mcp__{server_name}__{tool_name}"
|
|
25
|
+
readable = _INVALID_TOOL_NAME_CHARS.sub("_", raw).strip("_") or "mcp_tool"
|
|
26
|
+
digest = hashlib.sha1(f"{server_name}\0{tool_name}".encode("utf-8")).hexdigest()[:8]
|
|
27
|
+
suffix = f"_{digest}"
|
|
28
|
+
if len(readable) + len(suffix) > _LLM_TOOL_NAME_MAX:
|
|
29
|
+
readable = readable[: _LLM_TOOL_NAME_MAX - len(suffix)].rstrip("_-") or "mcp_tool"
|
|
30
|
+
return f"{readable}{suffix}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class McpToolWrapper(BaseTool):
|
|
34
|
+
"""Wraps an MCP server tool as a voidx BaseTool."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, client: McpClient, tool_def: McpToolDef, server_name: str) -> None:
|
|
37
|
+
self._client = client
|
|
38
|
+
self._tool_def = tool_def
|
|
39
|
+
self._server = server_name
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def id(self) -> str:
|
|
43
|
+
return mcp_tool_id(self._server, self._tool_def.name)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def description(self) -> str:
|
|
47
|
+
desc = self._tool_def.description or f"MCP tool from {self._server}"
|
|
48
|
+
return f"[MCP:{self._server}] {desc}"
|
|
49
|
+
|
|
50
|
+
def parameters_schema(self) -> dict[str, Any]:
|
|
51
|
+
return self._tool_def.inputSchema or {"type": "object", "properties": {}}
|
|
52
|
+
|
|
53
|
+
async def execute(self, args: dict[str, Any], ctx: ToolContext) -> ToolResult:
|
|
54
|
+
if not self._client.healthy:
|
|
55
|
+
return ToolResult(
|
|
56
|
+
output=f"MCP server '{self._server}' is not connected. "
|
|
57
|
+
f"Status: {self._client.status}."
|
|
58
|
+
f"{' ' + self._client.error_message if self._client.error_message else ''}",
|
|
59
|
+
metadata={"error": True, "server": self._server},
|
|
60
|
+
)
|
|
61
|
+
try:
|
|
62
|
+
result: McpCallResult = await self._client.call_tool(self._tool_def.name, args)
|
|
63
|
+
except (McpConnectionError, McpTimeoutError, McpProtocolError) as e:
|
|
64
|
+
return ToolResult(
|
|
65
|
+
output=f"MCP server '{self._server}' is unavailable: {e}",
|
|
66
|
+
metadata={"error": True, "server": self._server, "error_type": type(e).__name__},
|
|
67
|
+
)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
return ToolResult(
|
|
70
|
+
output=f"MCP tool '{self._tool_def.name}' error: {e}",
|
|
71
|
+
metadata={"error": True, "server": self._server, "error_type": type(e).__name__},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
output = format_mcp_call_result(result)
|
|
75
|
+
meta: dict[str, Any] = {"server": self._server}
|
|
76
|
+
if result.isError:
|
|
77
|
+
meta["error"] = True
|
|
78
|
+
|
|
79
|
+
return ToolResult(output=output, metadata=meta)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_mcp_call_result(result: McpCallResult) -> str:
|
|
83
|
+
parts = [_format_content_block(block) for block in result.content]
|
|
84
|
+
if result.structured_content is not None:
|
|
85
|
+
parts.append(
|
|
86
|
+
"Structured content:\n"
|
|
87
|
+
+ json.dumps(result.structured_content, ensure_ascii=False, indent=2, default=str)
|
|
88
|
+
)
|
|
89
|
+
rendered = [part for part in parts if part]
|
|
90
|
+
return "\n".join(rendered) if rendered else "(empty response)"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _format_content_block(block: Any) -> str:
|
|
94
|
+
if not isinstance(block, dict):
|
|
95
|
+
return str(block) if block is not None else ""
|
|
96
|
+
|
|
97
|
+
block_type = block.get("type", "")
|
|
98
|
+
if block_type == "text":
|
|
99
|
+
text = block.get("text", "")
|
|
100
|
+
return text if isinstance(text, str) else str(text)
|
|
101
|
+
|
|
102
|
+
if block_type == "image":
|
|
103
|
+
mime = block.get("mimeType") or block.get("mime_type") or "unknown"
|
|
104
|
+
data = block.get("data", "")
|
|
105
|
+
size = len(data) if isinstance(data, str) else 0
|
|
106
|
+
return f"[image {mime}, {size} base64 chars]"
|
|
107
|
+
|
|
108
|
+
if block_type == "resource":
|
|
109
|
+
resource = block.get("resource")
|
|
110
|
+
if isinstance(resource, dict):
|
|
111
|
+
uri = resource.get("uri", "resource")
|
|
112
|
+
mime = resource.get("mimeType") or resource.get("mime_type") or ""
|
|
113
|
+
header = f"[resource {uri}{f' ({mime})' if mime else ''}]"
|
|
114
|
+
text = resource.get("text")
|
|
115
|
+
if isinstance(text, str):
|
|
116
|
+
return f"{header}\n{text}"
|
|
117
|
+
blob = resource.get("blob")
|
|
118
|
+
if isinstance(blob, str):
|
|
119
|
+
return f"{header}\n[{len(blob)} base64 chars]"
|
|
120
|
+
return header
|
|
121
|
+
|
|
122
|
+
return json.dumps(block, ensure_ascii=False, sort_keys=True, default=str)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Built-in MCP servers shipped with voidx."""
|
voidx/mcp_servers/web.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Built-in line-delimited JSON-RPC MCP server for web tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from voidx.mcp.schema import MCP_PROTOCOL_VERSION
|
|
11
|
+
from voidx.tools.base import BaseTool, ToolContext
|
|
12
|
+
from voidx.tools.webfetch import WebFetchTool
|
|
13
|
+
from voidx.tools.websearch import WebSearchTool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _tools() -> dict[str, BaseTool]:
|
|
17
|
+
return {
|
|
18
|
+
"web_search": WebSearchTool(settings=None),
|
|
19
|
+
"web_fetch": WebFetchTool(settings=None),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def _handle(message: dict[str, Any], tools: dict[str, BaseTool]) -> dict[str, Any] | None:
|
|
24
|
+
method = message.get("method")
|
|
25
|
+
msg_id = message.get("id")
|
|
26
|
+
|
|
27
|
+
if method == "initialize":
|
|
28
|
+
params = message.get("params", {})
|
|
29
|
+
protocol_version = params.get("protocolVersion") or MCP_PROTOCOL_VERSION
|
|
30
|
+
return _result(msg_id, {
|
|
31
|
+
"protocolVersion": protocol_version,
|
|
32
|
+
"capabilities": {"tools": {}},
|
|
33
|
+
"serverInfo": {"name": "voidx-web", "version": "1.0.0"},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
if method == "notifications/initialized":
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
if method == "tools/list":
|
|
40
|
+
return _result(msg_id, {
|
|
41
|
+
"tools": [
|
|
42
|
+
{
|
|
43
|
+
"name": name,
|
|
44
|
+
"description": tool.description,
|
|
45
|
+
"inputSchema": tool.parameters_schema(),
|
|
46
|
+
}
|
|
47
|
+
for name, tool in tools.items()
|
|
48
|
+
]
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if method == "tools/call":
|
|
52
|
+
params = message.get("params", {})
|
|
53
|
+
name = params.get("name", "")
|
|
54
|
+
args = params.get("arguments", {})
|
|
55
|
+
tool = tools.get(name)
|
|
56
|
+
if tool is None:
|
|
57
|
+
return _error(msg_id, -32602, f"Unknown tool: {name}")
|
|
58
|
+
result = await tool.execute(args if isinstance(args, dict) else {}, ToolContext(workspace="."))
|
|
59
|
+
is_error = bool(result.metadata.get("error"))
|
|
60
|
+
return _result(msg_id, {
|
|
61
|
+
"content": [{"type": "text", "text": result.output}],
|
|
62
|
+
"isError": is_error,
|
|
63
|
+
"structuredContent": result.metadata,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if method == "shutdown":
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
if msg_id is None:
|
|
70
|
+
return None
|
|
71
|
+
return _error(msg_id, -32601, f"Method not found: {method}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _result(msg_id: Any, result: Any) -> dict[str, Any]:
|
|
75
|
+
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _error(msg_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
79
|
+
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def amain() -> None:
|
|
83
|
+
tools = _tools()
|
|
84
|
+
while True:
|
|
85
|
+
raw = await asyncio.to_thread(sys.stdin.readline)
|
|
86
|
+
if not raw:
|
|
87
|
+
break
|
|
88
|
+
try:
|
|
89
|
+
message = json.loads(raw)
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
continue
|
|
92
|
+
if message.get("method") == "shutdown":
|
|
93
|
+
break
|
|
94
|
+
response = await _handle(message, tools)
|
|
95
|
+
if response is not None:
|
|
96
|
+
print(json.dumps(response, ensure_ascii=False), flush=True)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main() -> None:
|
|
100
|
+
asyncio.run(amain())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
main()
|
voidx/memory/__init__.py
ADDED
|
File without changes
|