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.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. 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