dulus 0.2.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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
dulus_mcp/client.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""MCP client: stdio and HTTP/SSE transports, JSON-RPC 2.0 protocol."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .types import (
|
|
12
|
+
MCPServerConfig, MCPServerState, MCPTool, MCPTransport,
|
|
13
|
+
INIT_PARAMS, make_notification, make_request,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ── Stdio transport ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
class StdioTransport:
|
|
20
|
+
"""Bidirectional JSON-RPC over a subprocess's stdin/stdout.
|
|
21
|
+
|
|
22
|
+
Messages are newline-delimited JSON objects (one per line).
|
|
23
|
+
Responses are matched to requests by 'id'.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config: MCPServerConfig):
|
|
27
|
+
self._config = config
|
|
28
|
+
self._process: Optional[subprocess.Popen] = None
|
|
29
|
+
self._lock = threading.Lock()
|
|
30
|
+
self._next_id = 1
|
|
31
|
+
self._pending: Dict[int, dict] = {} # id → {"event": Event, "result": ...}
|
|
32
|
+
self._reader: Optional[threading.Thread] = None
|
|
33
|
+
self._stderr_reader: Optional[threading.Thread] = None
|
|
34
|
+
self._running = False
|
|
35
|
+
self._stderr_lines: List[str] = []
|
|
36
|
+
|
|
37
|
+
def start(self) -> None:
|
|
38
|
+
env = {**os.environ, **(self._config.env or {})}
|
|
39
|
+
cmd = [self._config.command] + list(self._config.args or [])
|
|
40
|
+
self._process = subprocess.Popen(
|
|
41
|
+
cmd,
|
|
42
|
+
stdin=subprocess.PIPE,
|
|
43
|
+
stdout=subprocess.PIPE,
|
|
44
|
+
stderr=subprocess.PIPE,
|
|
45
|
+
env=env,
|
|
46
|
+
)
|
|
47
|
+
self._running = True
|
|
48
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
49
|
+
self._reader.start()
|
|
50
|
+
self._stderr_reader = threading.Thread(target=self._stderr_loop, daemon=True)
|
|
51
|
+
self._stderr_reader.start()
|
|
52
|
+
|
|
53
|
+
def _read_loop(self) -> None:
|
|
54
|
+
while self._running and self._process:
|
|
55
|
+
try:
|
|
56
|
+
raw = self._process.stdout.readline()
|
|
57
|
+
if not raw:
|
|
58
|
+
break
|
|
59
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
60
|
+
if not line:
|
|
61
|
+
continue
|
|
62
|
+
msg = json.loads(line)
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
65
|
+
# Dispatch: response (has "id") vs notification (no "id")
|
|
66
|
+
msg_id = msg.get("id")
|
|
67
|
+
if msg_id is not None and msg_id in self._pending:
|
|
68
|
+
holder = self._pending[msg_id]
|
|
69
|
+
holder["result"] = msg
|
|
70
|
+
holder["event"].set()
|
|
71
|
+
|
|
72
|
+
def _stderr_loop(self) -> None:
|
|
73
|
+
while self._running and self._process:
|
|
74
|
+
try:
|
|
75
|
+
raw = self._process.stderr.readline()
|
|
76
|
+
if not raw:
|
|
77
|
+
break
|
|
78
|
+
self._stderr_lines.append(raw.decode("utf-8", errors="replace").rstrip())
|
|
79
|
+
except Exception:
|
|
80
|
+
break
|
|
81
|
+
|
|
82
|
+
def _send_raw(self, msg: dict) -> None:
|
|
83
|
+
line = (json.dumps(msg) + "\n").encode("utf-8")
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._process.stdin.write(line)
|
|
86
|
+
self._process.stdin.flush()
|
|
87
|
+
|
|
88
|
+
def request(self, method: str, params: Optional[dict] = None, timeout: Optional[int] = None) -> dict:
|
|
89
|
+
"""Send a JSON-RPC request and wait for the response."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
req_id = self._next_id
|
|
92
|
+
self._next_id += 1
|
|
93
|
+
event = threading.Event()
|
|
94
|
+
holder: dict = {"event": event, "result": None}
|
|
95
|
+
self._pending[req_id] = holder
|
|
96
|
+
msg = make_request(method, params, req_id)
|
|
97
|
+
self._send_raw(msg)
|
|
98
|
+
wait_secs = timeout or self._config.timeout
|
|
99
|
+
event.wait(timeout=wait_secs)
|
|
100
|
+
self._pending.pop(req_id, None)
|
|
101
|
+
result = holder["result"]
|
|
102
|
+
if result is None:
|
|
103
|
+
raise TimeoutError(f"MCP server '{self._config.name}' timed out on '{method}'")
|
|
104
|
+
if "error" in result:
|
|
105
|
+
err = result["error"]
|
|
106
|
+
raise RuntimeError(f"MCP error {err.get('code')}: {err.get('message')}")
|
|
107
|
+
return result.get("result", {})
|
|
108
|
+
|
|
109
|
+
def notify(self, method: str, params: Optional[dict] = None) -> None:
|
|
110
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
111
|
+
self._send_raw(make_notification(method, params))
|
|
112
|
+
|
|
113
|
+
def stop(self) -> None:
|
|
114
|
+
self._running = False
|
|
115
|
+
if self._process:
|
|
116
|
+
try:
|
|
117
|
+
self._process.terminate()
|
|
118
|
+
self._process.wait(timeout=3)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
self._process = None
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def alive(self) -> bool:
|
|
125
|
+
return self._process is not None and self._process.poll() is None
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def stderr_output(self) -> str:
|
|
129
|
+
return "\n".join(self._stderr_lines[-20:])
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── HTTP / SSE transport ──────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
class HttpTransport:
|
|
135
|
+
"""HTTP-based MCP transport (POST-based streamable HTTP or SSE endpoint).
|
|
136
|
+
|
|
137
|
+
For SSE servers: sends messages via POST to the SSE session endpoint.
|
|
138
|
+
For HTTP servers: sends messages via POST and reads response directly.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, config: MCPServerConfig):
|
|
142
|
+
self._config = config
|
|
143
|
+
self._session_url: Optional[str] = None
|
|
144
|
+
self._lock = threading.Lock()
|
|
145
|
+
self._next_id = 1
|
|
146
|
+
self._client = None # httpx.Client, loaded lazily
|
|
147
|
+
self._sse_thread: Optional[threading.Thread] = None
|
|
148
|
+
self._sse_pending: Dict[int, dict] = {}
|
|
149
|
+
self._running = False
|
|
150
|
+
|
|
151
|
+
def _get_client(self):
|
|
152
|
+
if self._client is None:
|
|
153
|
+
try:
|
|
154
|
+
import httpx
|
|
155
|
+
self._client = httpx.Client(
|
|
156
|
+
headers=self._config.headers,
|
|
157
|
+
timeout=self._config.timeout,
|
|
158
|
+
follow_redirects=True,
|
|
159
|
+
)
|
|
160
|
+
except ImportError:
|
|
161
|
+
raise RuntimeError("httpx is required for HTTP/SSE MCP transport: pip install httpx")
|
|
162
|
+
return self._client
|
|
163
|
+
|
|
164
|
+
def start(self) -> None:
|
|
165
|
+
"""For SSE transport: connect to the /sse endpoint and get session URL."""
|
|
166
|
+
if self._config.transport == MCPTransport.SSE:
|
|
167
|
+
self._start_sse()
|
|
168
|
+
else:
|
|
169
|
+
# Pure HTTP: no persistent connection needed
|
|
170
|
+
self._session_url = self._config.url
|
|
171
|
+
|
|
172
|
+
def _start_sse(self) -> None:
|
|
173
|
+
"""Open SSE stream to get session endpoint, then start background reader."""
|
|
174
|
+
import httpx
|
|
175
|
+
client = self._get_client()
|
|
176
|
+
self._running = True
|
|
177
|
+
|
|
178
|
+
# Initial SSE connect — first event should be 'endpoint' with session URL
|
|
179
|
+
endpoint_event = threading.Event()
|
|
180
|
+
endpoint_holder: dict = {"url": None, "error": None}
|
|
181
|
+
|
|
182
|
+
def _sse_reader():
|
|
183
|
+
try:
|
|
184
|
+
with client.stream("GET", self._config.url) as resp:
|
|
185
|
+
resp.raise_for_status()
|
|
186
|
+
event_type = None
|
|
187
|
+
for line in resp.iter_lines():
|
|
188
|
+
if not self._running:
|
|
189
|
+
break
|
|
190
|
+
if line.startswith("event:"):
|
|
191
|
+
event_type = line[6:].strip()
|
|
192
|
+
elif line.startswith("data:"):
|
|
193
|
+
data = line[5:].strip()
|
|
194
|
+
if event_type == "endpoint":
|
|
195
|
+
# Session URL may be relative or absolute
|
|
196
|
+
base = self._config.url.rsplit("/sse", 1)[0]
|
|
197
|
+
session_url = data if data.startswith("http") else base + data
|
|
198
|
+
endpoint_holder["url"] = session_url
|
|
199
|
+
self._session_url = session_url
|
|
200
|
+
endpoint_event.set()
|
|
201
|
+
elif event_type == "message":
|
|
202
|
+
try:
|
|
203
|
+
msg = json.loads(data)
|
|
204
|
+
msg_id = msg.get("id")
|
|
205
|
+
if msg_id is not None and msg_id in self._sse_pending:
|
|
206
|
+
holder = self._sse_pending[msg_id]
|
|
207
|
+
holder["result"] = msg
|
|
208
|
+
holder["event"].set()
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
except Exception as e:
|
|
212
|
+
endpoint_holder["error"] = str(e)
|
|
213
|
+
endpoint_event.set()
|
|
214
|
+
|
|
215
|
+
self._sse_thread = threading.Thread(target=_sse_reader, daemon=True)
|
|
216
|
+
self._sse_thread.start()
|
|
217
|
+
endpoint_event.wait(timeout=10)
|
|
218
|
+
if endpoint_holder.get("error"):
|
|
219
|
+
raise RuntimeError(f"SSE connect failed: {endpoint_holder['error']}")
|
|
220
|
+
if not self._session_url:
|
|
221
|
+
raise RuntimeError("SSE server did not send 'endpoint' event")
|
|
222
|
+
|
|
223
|
+
def request(self, method: str, params: Optional[dict] = None, timeout: Optional[int] = None) -> dict:
|
|
224
|
+
with self._lock:
|
|
225
|
+
req_id = self._next_id
|
|
226
|
+
self._next_id += 1
|
|
227
|
+
|
|
228
|
+
msg = make_request(method, params, req_id)
|
|
229
|
+
client = self._get_client()
|
|
230
|
+
wait_secs = timeout or self._config.timeout
|
|
231
|
+
|
|
232
|
+
if self._config.transport == MCPTransport.SSE:
|
|
233
|
+
# For SSE: POST to session URL, wait for response on SSE stream
|
|
234
|
+
event = threading.Event()
|
|
235
|
+
holder: dict = {"event": event, "result": None}
|
|
236
|
+
self._sse_pending[req_id] = holder
|
|
237
|
+
client.post(self._session_url, json=msg)
|
|
238
|
+
event.wait(timeout=wait_secs)
|
|
239
|
+
self._sse_pending.pop(req_id, None)
|
|
240
|
+
result = holder["result"]
|
|
241
|
+
else:
|
|
242
|
+
# For HTTP: POST and get response directly
|
|
243
|
+
resp = client.post(self._session_url or self._config.url, json=msg, timeout=wait_secs)
|
|
244
|
+
resp.raise_for_status()
|
|
245
|
+
result = resp.json()
|
|
246
|
+
|
|
247
|
+
if result is None:
|
|
248
|
+
raise TimeoutError(f"MCP server '{self._config.name}' timed out on '{method}'")
|
|
249
|
+
if "error" in result:
|
|
250
|
+
err = result["error"]
|
|
251
|
+
raise RuntimeError(f"MCP error {err.get('code')}: {err.get('message')}")
|
|
252
|
+
return result.get("result", {})
|
|
253
|
+
|
|
254
|
+
def notify(self, method: str, params: Optional[dict] = None) -> None:
|
|
255
|
+
client = self._get_client()
|
|
256
|
+
msg = make_notification(method, params)
|
|
257
|
+
url = self._session_url or self._config.url
|
|
258
|
+
try:
|
|
259
|
+
client.post(url, json=msg)
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
def stop(self) -> None:
|
|
264
|
+
self._running = False
|
|
265
|
+
if self._client:
|
|
266
|
+
try:
|
|
267
|
+
self._client.close()
|
|
268
|
+
except Exception:
|
|
269
|
+
pass
|
|
270
|
+
self._client = None
|
|
271
|
+
|
|
272
|
+
@property
|
|
273
|
+
def alive(self) -> bool:
|
|
274
|
+
return self._session_url is not None or self._config.transport == MCPTransport.HTTP
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ── High-level MCP client ─────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
class MCPClient:
|
|
280
|
+
"""Manages the lifecycle of one MCP server connection.
|
|
281
|
+
|
|
282
|
+
Protocol flow:
|
|
283
|
+
connect() → initialize handshake → notifications/initialized
|
|
284
|
+
list_tools() → tools/list
|
|
285
|
+
call_tool() → tools/call
|
|
286
|
+
disconnect() → cleanup
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(self, config: MCPServerConfig):
|
|
290
|
+
self.config = config
|
|
291
|
+
self.state = MCPServerState.DISCONNECTED
|
|
292
|
+
self._transport: Optional[Any] = None
|
|
293
|
+
self._server_info: dict = {}
|
|
294
|
+
self._capabilities: dict = {}
|
|
295
|
+
self._tools: List[MCPTool] = []
|
|
296
|
+
self._error: str = ""
|
|
297
|
+
|
|
298
|
+
# ── Connection ────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
def connect(self) -> None:
|
|
301
|
+
if self.state == MCPServerState.CONNECTED:
|
|
302
|
+
return
|
|
303
|
+
self.state = MCPServerState.CONNECTING
|
|
304
|
+
self._error = ""
|
|
305
|
+
try:
|
|
306
|
+
self._transport = self._make_transport()
|
|
307
|
+
self._transport.start()
|
|
308
|
+
self._handshake()
|
|
309
|
+
self.state = MCPServerState.CONNECTED
|
|
310
|
+
except Exception as e:
|
|
311
|
+
self.state = MCPServerState.ERROR
|
|
312
|
+
self._error = str(e)
|
|
313
|
+
raise
|
|
314
|
+
|
|
315
|
+
def _make_transport(self):
|
|
316
|
+
t = self.config.transport
|
|
317
|
+
if t == MCPTransport.STDIO:
|
|
318
|
+
return StdioTransport(self.config)
|
|
319
|
+
if t in (MCPTransport.SSE, MCPTransport.HTTP):
|
|
320
|
+
return HttpTransport(self.config)
|
|
321
|
+
raise ValueError(f"Unsupported MCP transport: {t}")
|
|
322
|
+
|
|
323
|
+
def _handshake(self) -> None:
|
|
324
|
+
result = self._transport.request("initialize", INIT_PARAMS, timeout=15)
|
|
325
|
+
self._server_info = result.get("serverInfo", {})
|
|
326
|
+
self._capabilities = result.get("capabilities", {})
|
|
327
|
+
self._transport.notify("notifications/initialized")
|
|
328
|
+
|
|
329
|
+
def disconnect(self) -> None:
|
|
330
|
+
if self._transport:
|
|
331
|
+
self._transport.stop()
|
|
332
|
+
self._transport = None
|
|
333
|
+
self.state = MCPServerState.DISCONNECTED
|
|
334
|
+
|
|
335
|
+
def reconnect(self) -> None:
|
|
336
|
+
self.disconnect()
|
|
337
|
+
self.connect()
|
|
338
|
+
|
|
339
|
+
@property
|
|
340
|
+
def alive(self) -> bool:
|
|
341
|
+
return (
|
|
342
|
+
self.state == MCPServerState.CONNECTED
|
|
343
|
+
and self._transport is not None
|
|
344
|
+
and self._transport.alive
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# ── Tool discovery ────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
def list_tools(self) -> List[MCPTool]:
|
|
350
|
+
"""Fetch tool list from server and cache as MCPTool objects."""
|
|
351
|
+
if self.state != MCPServerState.CONNECTED:
|
|
352
|
+
raise RuntimeError(f"MCP server '{self.config.name}' is not connected")
|
|
353
|
+
|
|
354
|
+
if "tools" not in self._capabilities:
|
|
355
|
+
self._tools = []
|
|
356
|
+
return self._tools
|
|
357
|
+
|
|
358
|
+
result = self._transport.request("tools/list", timeout=15)
|
|
359
|
+
raw_tools = result.get("tools", [])
|
|
360
|
+
self._tools = [self._parse_tool(t) for t in raw_tools]
|
|
361
|
+
return self._tools
|
|
362
|
+
|
|
363
|
+
def _parse_tool(self, raw: dict) -> MCPTool:
|
|
364
|
+
tool_name = raw.get("name", "")
|
|
365
|
+
qualified = f"mcp__{self.config.name}__{tool_name}"
|
|
366
|
+
# Sanitize: replace non-alphanumeric with _ for API compatibility
|
|
367
|
+
qualified = "".join(c if c.isalnum() or c == "_" else "_" for c in qualified)
|
|
368
|
+
|
|
369
|
+
annotations = raw.get("annotations", {})
|
|
370
|
+
read_only = bool(annotations.get("readOnlyHint", False))
|
|
371
|
+
|
|
372
|
+
schema = raw.get("inputSchema", {"type": "object", "properties": {}})
|
|
373
|
+
# Ensure minimum valid JSON schema
|
|
374
|
+
if not isinstance(schema, dict):
|
|
375
|
+
schema = {"type": "object", "properties": {}}
|
|
376
|
+
|
|
377
|
+
return MCPTool(
|
|
378
|
+
server_name=self.config.name,
|
|
379
|
+
tool_name=tool_name,
|
|
380
|
+
qualified_name=qualified,
|
|
381
|
+
description=raw.get("description", ""),
|
|
382
|
+
input_schema=schema,
|
|
383
|
+
read_only=read_only,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# ── Tool invocation ───────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
def call_tool(self, tool_name: str, arguments: dict) -> str:
|
|
389
|
+
"""Call a tool by its original (non-qualified) name.
|
|
390
|
+
|
|
391
|
+
Returns the text content from the response, or an error string.
|
|
392
|
+
"""
|
|
393
|
+
if self.state != MCPServerState.CONNECTED:
|
|
394
|
+
raise RuntimeError(f"MCP server '{self.config.name}' is not connected")
|
|
395
|
+
|
|
396
|
+
params = {"name": tool_name, "arguments": arguments}
|
|
397
|
+
result = self._transport.request("tools/call", params, timeout=self.config.timeout)
|
|
398
|
+
|
|
399
|
+
is_error = result.get("isError", False)
|
|
400
|
+
content = result.get("content", [])
|
|
401
|
+
|
|
402
|
+
# Collect text content blocks
|
|
403
|
+
parts: List[str] = []
|
|
404
|
+
for block in content:
|
|
405
|
+
btype = block.get("type", "")
|
|
406
|
+
if btype == "text":
|
|
407
|
+
parts.append(block.get("text", ""))
|
|
408
|
+
elif btype == "image":
|
|
409
|
+
parts.append(f"[image: {block.get('mimeType', 'unknown')}]")
|
|
410
|
+
elif btype == "resource":
|
|
411
|
+
res = block.get("resource", {})
|
|
412
|
+
parts.append(f"[resource: {res.get('uri', '')}]")
|
|
413
|
+
|
|
414
|
+
text = "\n".join(parts) if parts else str(result)
|
|
415
|
+
if is_error:
|
|
416
|
+
return f"[MCP tool error]\n{text}"
|
|
417
|
+
return text
|
|
418
|
+
|
|
419
|
+
# ── Status ────────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
def status_line(self) -> str:
|
|
422
|
+
icon = {"connected": "✓", "connecting": "…", "disconnected": "○", "error": "✗"}.get(
|
|
423
|
+
self.state.value, "?"
|
|
424
|
+
)
|
|
425
|
+
server = self._server_info.get("name", self.config.name)
|
|
426
|
+
version = self._server_info.get("version", "")
|
|
427
|
+
tool_count = len(self._tools)
|
|
428
|
+
line = f"{icon} {self.config.name}"
|
|
429
|
+
if server and server != self.config.name:
|
|
430
|
+
line += f" ({server}"
|
|
431
|
+
if version:
|
|
432
|
+
line += f" v{version}"
|
|
433
|
+
line += ")"
|
|
434
|
+
if self.state == MCPServerState.CONNECTED:
|
|
435
|
+
line += f" [{tool_count} tool(s)]"
|
|
436
|
+
if self.state == MCPServerState.ERROR:
|
|
437
|
+
line += f" error: {self._error}"
|
|
438
|
+
return line
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ── Manager ───────────────────────────────────────────────────────────────────
|
|
442
|
+
|
|
443
|
+
class MCPManager:
|
|
444
|
+
"""Singleton that manages all configured MCP server connections."""
|
|
445
|
+
|
|
446
|
+
def __init__(self):
|
|
447
|
+
self._clients: Dict[str, MCPClient] = {}
|
|
448
|
+
|
|
449
|
+
def add_server(self, config: MCPServerConfig) -> MCPClient:
|
|
450
|
+
"""Register a server. Replaces any existing client with the same name."""
|
|
451
|
+
if config.name in self._clients:
|
|
452
|
+
try:
|
|
453
|
+
self._clients[config.name].disconnect()
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
client = MCPClient(config)
|
|
457
|
+
self._clients[config.name] = client
|
|
458
|
+
return client
|
|
459
|
+
|
|
460
|
+
def connect_all(self) -> Dict[str, Optional[str]]:
|
|
461
|
+
"""Connect to all registered servers. Returns {name: error_or_None}."""
|
|
462
|
+
errors: Dict[str, Optional[str]] = {}
|
|
463
|
+
for name, client in self._clients.items():
|
|
464
|
+
if client.config.disabled:
|
|
465
|
+
errors[name] = "disabled"
|
|
466
|
+
continue
|
|
467
|
+
try:
|
|
468
|
+
client.connect()
|
|
469
|
+
client.list_tools()
|
|
470
|
+
errors[name] = None
|
|
471
|
+
except Exception as e:
|
|
472
|
+
errors[name] = str(e)
|
|
473
|
+
return errors
|
|
474
|
+
|
|
475
|
+
def connect_server(self, name: str) -> MCPClient:
|
|
476
|
+
"""Connect (or reconnect) a single server by name."""
|
|
477
|
+
client = self._clients.get(name)
|
|
478
|
+
if client is None:
|
|
479
|
+
raise KeyError(f"MCP server '{name}' not configured")
|
|
480
|
+
if client.state != MCPServerState.CONNECTED:
|
|
481
|
+
client.connect()
|
|
482
|
+
client.list_tools()
|
|
483
|
+
return client
|
|
484
|
+
|
|
485
|
+
def all_tools(self) -> List[MCPTool]:
|
|
486
|
+
"""Return all tools from all connected servers."""
|
|
487
|
+
tools: List[MCPTool] = []
|
|
488
|
+
for client in self._clients.values():
|
|
489
|
+
if client.state == MCPServerState.CONNECTED:
|
|
490
|
+
tools.extend(client._tools)
|
|
491
|
+
return tools
|
|
492
|
+
|
|
493
|
+
def call_tool(self, qualified_name: str, arguments: dict) -> str:
|
|
494
|
+
"""Dispatch a tool call by qualified name (mcp__server__tool)."""
|
|
495
|
+
# Parse server and tool name from qualified name
|
|
496
|
+
parts = qualified_name.split("__", 2)
|
|
497
|
+
if len(parts) != 3 or parts[0] != "mcp":
|
|
498
|
+
raise ValueError(f"Invalid MCP tool name: {qualified_name}")
|
|
499
|
+
server_name = parts[1]
|
|
500
|
+
tool_name = parts[2]
|
|
501
|
+
|
|
502
|
+
client = self._clients.get(server_name)
|
|
503
|
+
if client is None:
|
|
504
|
+
raise RuntimeError(f"MCP server '{server_name}' not configured")
|
|
505
|
+
|
|
506
|
+
# Auto-reconnect if dropped
|
|
507
|
+
if not client.alive:
|
|
508
|
+
client.reconnect()
|
|
509
|
+
client.list_tools()
|
|
510
|
+
|
|
511
|
+
# Find the original tool name (un-sanitized)
|
|
512
|
+
original_name = tool_name
|
|
513
|
+
for t in client._tools:
|
|
514
|
+
if t.qualified_name == qualified_name:
|
|
515
|
+
original_name = t.tool_name
|
|
516
|
+
break
|
|
517
|
+
|
|
518
|
+
return client.call_tool(original_name, arguments)
|
|
519
|
+
|
|
520
|
+
def list_servers(self) -> List[MCPClient]:
|
|
521
|
+
return list(self._clients.values())
|
|
522
|
+
|
|
523
|
+
def disconnect_all(self) -> None:
|
|
524
|
+
for client in self._clients.values():
|
|
525
|
+
try:
|
|
526
|
+
client.disconnect()
|
|
527
|
+
except Exception:
|
|
528
|
+
pass
|
|
529
|
+
|
|
530
|
+
def reload_server(self, name: str) -> None:
|
|
531
|
+
client = self._clients.get(name)
|
|
532
|
+
if client:
|
|
533
|
+
client.reconnect()
|
|
534
|
+
client.list_tools()
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
# ── Module-level singleton ────────────────────────────────────────────────────
|
|
538
|
+
|
|
539
|
+
_manager: Optional[MCPManager] = None
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def get_mcp_manager() -> MCPManager:
|
|
543
|
+
global _manager
|
|
544
|
+
if _manager is None:
|
|
545
|
+
_manager = MCPManager()
|
|
546
|
+
return _manager
|
dulus_mcp/config.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Load MCP server configs from .mcp.json files (project + user level).
|
|
2
|
+
|
|
3
|
+
Config search order (project-level overrides user-level by server name):
|
|
4
|
+
1. ~/.dulus/mcp.json — user-level, lowest priority
|
|
5
|
+
2. <cwd>/.mcp.json — project-level, highest priority
|
|
6
|
+
|
|
7
|
+
File format (matches Claude Code's .mcp.json format):
|
|
8
|
+
{
|
|
9
|
+
"mcpServers": {
|
|
10
|
+
"my-server": {
|
|
11
|
+
"type": "stdio",
|
|
12
|
+
"command": "uvx",
|
|
13
|
+
"args": ["mcp-server-git", "--repository", "."]
|
|
14
|
+
},
|
|
15
|
+
"remote-server": {
|
|
16
|
+
"type": "sse",
|
|
17
|
+
"url": "http://localhost:8080/sse",
|
|
18
|
+
"headers": {"Authorization": "Bearer token"}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Dict, List
|
|
28
|
+
|
|
29
|
+
from .types import MCPServerConfig
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── Config file locations ─────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
USER_MCP_CONFIG = Path.home() / ".dulus" / "mcp.json"
|
|
35
|
+
PROJECT_MCP_NAME = ".mcp.json" # looked up relative to cwd
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_file(path: Path) -> Dict[str, dict]:
|
|
39
|
+
"""Read a single mcp.json file and return the mcpServers dict."""
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return {}
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
44
|
+
return data.get("mcpServers", {})
|
|
45
|
+
except Exception:
|
|
46
|
+
return {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_mcp_configs() -> Dict[str, MCPServerConfig]:
|
|
50
|
+
"""Return all MCP server configs, project-level overriding user-level."""
|
|
51
|
+
# User-level first (lowest priority)
|
|
52
|
+
servers: Dict[str, dict] = _load_file(USER_MCP_CONFIG)
|
|
53
|
+
|
|
54
|
+
# Walk up from cwd to find .mcp.json (up to 10 levels)
|
|
55
|
+
p = Path.cwd()
|
|
56
|
+
for _ in range(10):
|
|
57
|
+
candidate = p / PROJECT_MCP_NAME
|
|
58
|
+
if candidate.exists():
|
|
59
|
+
project_servers = _load_file(candidate)
|
|
60
|
+
servers.update(project_servers) # project wins
|
|
61
|
+
break
|
|
62
|
+
parent = p.parent
|
|
63
|
+
if parent == p:
|
|
64
|
+
break
|
|
65
|
+
p = parent
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
name: MCPServerConfig.from_dict(name, raw)
|
|
69
|
+
for name, raw in servers.items()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def save_user_mcp_config(servers: Dict[str, dict]) -> None:
|
|
74
|
+
"""Write (or update) the user-level MCP config file."""
|
|
75
|
+
USER_MCP_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
existing: dict = {}
|
|
77
|
+
if USER_MCP_CONFIG.exists():
|
|
78
|
+
try:
|
|
79
|
+
existing = json.loads(USER_MCP_CONFIG.read_text(encoding="utf-8-sig"))
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
existing["mcpServers"] = servers
|
|
83
|
+
USER_MCP_CONFIG.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def add_server_to_user_config(name: str, raw: dict) -> None:
|
|
87
|
+
"""Append or update one server entry in the user MCP config."""
|
|
88
|
+
existing: dict = {}
|
|
89
|
+
if USER_MCP_CONFIG.exists():
|
|
90
|
+
try:
|
|
91
|
+
existing = json.loads(USER_MCP_CONFIG.read_text(encoding="utf-8-sig"))
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
mcp_servers = existing.get("mcpServers", {})
|
|
95
|
+
mcp_servers[name] = raw
|
|
96
|
+
existing["mcpServers"] = mcp_servers
|
|
97
|
+
USER_MCP_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
USER_MCP_CONFIG.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def remove_server_from_user_config(name: str) -> bool:
|
|
102
|
+
"""Remove a server from the user MCP config. Returns True if found."""
|
|
103
|
+
if not USER_MCP_CONFIG.exists():
|
|
104
|
+
return False
|
|
105
|
+
try:
|
|
106
|
+
existing = json.loads(USER_MCP_CONFIG.read_text(encoding="utf-8-sig"))
|
|
107
|
+
mcp_servers = existing.get("mcpServers", {})
|
|
108
|
+
if name not in mcp_servers:
|
|
109
|
+
return False
|
|
110
|
+
del mcp_servers[name]
|
|
111
|
+
existing["mcpServers"] = mcp_servers
|
|
112
|
+
USER_MCP_CONFIG.write_text(json.dumps(existing, indent=2), encoding="utf-8")
|
|
113
|
+
return True
|
|
114
|
+
except Exception:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def list_config_files() -> List[Path]:
|
|
119
|
+
"""Return paths of all mcp.json config files that exist."""
|
|
120
|
+
found = []
|
|
121
|
+
if USER_MCP_CONFIG.exists():
|
|
122
|
+
found.append(USER_MCP_CONFIG)
|
|
123
|
+
p = Path.cwd()
|
|
124
|
+
for _ in range(10):
|
|
125
|
+
candidate = p / PROJECT_MCP_NAME
|
|
126
|
+
if candidate.exists():
|
|
127
|
+
found.append(candidate)
|
|
128
|
+
break
|
|
129
|
+
parent = p.parent
|
|
130
|
+
if parent == p:
|
|
131
|
+
break
|
|
132
|
+
p = parent
|
|
133
|
+
return found
|