drydock-cli 3.0.53__tar.gz → 3.0.55__tar.gz
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.
- {drydock_cli-3.0.53/drydock_cli.egg-info → drydock_cli-3.0.55}/PKG-INFO +1 -1
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/cli.py +32 -0
- drydock_cli-3.0.55/drydock/mcp.py +213 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/tools/__init__.py +88 -12
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/tui/app.py +24 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/tui/widgets.py +1 -1
- {drydock_cli-3.0.53 → drydock_cli-3.0.55/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock_cli.egg-info/SOURCES.txt +3 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/pyproject.toml +1 -1
- drydock_cli-3.0.55/tests/test_dispatch.py +62 -0
- drydock_cli-3.0.55/tests/test_mcp.py +57 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/LICENSE +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/NOTICE +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/README.md +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/__init__.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/agent.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/config.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/detect.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/gittools.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/graphrag.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/guards.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/providers.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/skills.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock/web.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/setup.cfg +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_bash_process_group.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_bash_timeout_network.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_compact_command.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_config.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_config_migration.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_context_limit_config.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_gittools.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_graphrag.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_oneshot_unreachable.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_runaway_repetition.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_skills.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_tuning.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_vision_input.py +0 -0
- {drydock_cli-3.0.53 → drydock_cli-3.0.55}/tests/test_web_tools.py +0 -0
|
@@ -268,6 +268,33 @@ def _first_run_setup(cfg: dict, cfg_path, cfgmod) -> tuple[dict, str]:
|
|
|
268
268
|
return cfg, f"⚓ Using {model} at {url}"
|
|
269
269
|
|
|
270
270
|
|
|
271
|
+
def _connect_mcp(config: dict) -> None:
|
|
272
|
+
"""Start configured MCP servers and register their tools into the registry.
|
|
273
|
+
Quiet + crash-proof: any failure is logged to config['mcp_log'], never
|
|
274
|
+
raised. No-op when no mcp.json exists."""
|
|
275
|
+
import atexit
|
|
276
|
+
|
|
277
|
+
from drydock import mcp
|
|
278
|
+
from drydock.tool_registry import ToolDef, register
|
|
279
|
+
|
|
280
|
+
log: list[str] = []
|
|
281
|
+
try:
|
|
282
|
+
schemas = mcp.connect_all(config.get("cwd") or os.getcwd(), log=log.append)
|
|
283
|
+
except Exception as e: # noqa: BLE001 — never block startup on MCP
|
|
284
|
+
config["mcp_log"] = [f"MCP init error: {e}"]
|
|
285
|
+
return
|
|
286
|
+
for sch in schemas:
|
|
287
|
+
_, server, tool = sch["name"].split("__", 2)
|
|
288
|
+
register(ToolDef(
|
|
289
|
+
name=sch["name"], schema=sch,
|
|
290
|
+
func=(lambda params, cfg, s=server, t=tool: mcp.call(s, t, params)),
|
|
291
|
+
read_only=False,
|
|
292
|
+
))
|
|
293
|
+
config["mcp_log"] = log
|
|
294
|
+
if mcp.connected():
|
|
295
|
+
atexit.register(mcp.shutdown)
|
|
296
|
+
|
|
297
|
+
|
|
271
298
|
def main():
|
|
272
299
|
parser = argparse.ArgumentParser(description="DryDock — local coding agent")
|
|
273
300
|
parser.add_argument(
|
|
@@ -349,6 +376,11 @@ def main():
|
|
|
349
376
|
# call load_project_instructions directly (see run_interactive/run_oneshot).
|
|
350
377
|
config["project_instructions"] = load_project_instructions()
|
|
351
378
|
|
|
379
|
+
# Connect any configured MCP servers and register their tools so the model
|
|
380
|
+
# can call them like native tools. Failures are skipped (never block start);
|
|
381
|
+
# status is stashed for /mcp. Servers are torn down at exit.
|
|
382
|
+
_connect_mcp(config)
|
|
383
|
+
|
|
352
384
|
if args.prompt:
|
|
353
385
|
run_oneshot(args.prompt, config)
|
|
354
386
|
elif args.cli:
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) client — connect to MCP servers, expose their
|
|
2
|
+
tools to the agent.
|
|
3
|
+
|
|
4
|
+
Clean-room, stdlib only (no `mcp` SDK dependency): a minimal JSON-RPC 2.0 client
|
|
5
|
+
over the stdio transport (newline-delimited JSON), which is what local MCP
|
|
6
|
+
servers use. We do the initialize handshake, list the server's tools, and call
|
|
7
|
+
them on demand; results come back as text the model reads like any tool output.
|
|
8
|
+
|
|
9
|
+
Config (standard mcpServers shape, like Claude Desktop):
|
|
10
|
+
~/.drydock/mcp.json and/or <cwd>/.drydock/mcp.json
|
|
11
|
+
{
|
|
12
|
+
"mcpServers": {
|
|
13
|
+
"files": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data"]},
|
|
14
|
+
"weather": {"command": "python", "args": ["weather_server.py"], "env": {"KEY": "..."}}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Tools are registered as ``mcp__<server>__<tool>``. Everything degrades cleanly:
|
|
19
|
+
a server that fails to start or times out is skipped with a logged note, never
|
|
20
|
+
crashing drydock.
|
|
21
|
+
|
|
22
|
+
All logic original to Drydock.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import subprocess
|
|
29
|
+
import threading
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
_PROTOCOL_VERSION = "2024-11-05"
|
|
33
|
+
# Live servers for this process, by name. The tool funcs look servers up here.
|
|
34
|
+
_SERVERS: dict[str, "MCPServer"] = {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MCPError(RuntimeError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class MCPServer:
|
|
42
|
+
"""One stdio MCP server subprocess + a synchronous JSON-RPC channel."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, name: str, command: str, args: list[str], env: dict | None = None):
|
|
45
|
+
self.name = name
|
|
46
|
+
self.command = command
|
|
47
|
+
self.args = args or []
|
|
48
|
+
self.env = env or {}
|
|
49
|
+
self.proc: subprocess.Popen | None = None
|
|
50
|
+
self.tools: list[dict] = []
|
|
51
|
+
self._id = 0
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
|
|
54
|
+
# ── transport ──────────────────────────────────────────────────────────
|
|
55
|
+
def _send(self, msg: dict) -> None:
|
|
56
|
+
assert self.proc and self.proc.stdin
|
|
57
|
+
self.proc.stdin.write(json.dumps(msg) + "\n")
|
|
58
|
+
self.proc.stdin.flush()
|
|
59
|
+
|
|
60
|
+
def _read_result(self, want_id: int, timeout: float = 20.0) -> dict:
|
|
61
|
+
"""Read newline-delimited JSON until the response with want_id arrives.
|
|
62
|
+
Notifications / other-id messages are skipped. (Requests are serialized
|
|
63
|
+
under _lock, so responses effectively arrive in order.)"""
|
|
64
|
+
assert self.proc and self.proc.stdout
|
|
65
|
+
import time
|
|
66
|
+
deadline = time.monotonic() + timeout
|
|
67
|
+
while True:
|
|
68
|
+
if time.monotonic() > deadline:
|
|
69
|
+
raise MCPError(f"{self.name}: timed out waiting for response {want_id}")
|
|
70
|
+
line = self.proc.stdout.readline()
|
|
71
|
+
if line == "":
|
|
72
|
+
raise MCPError(f"{self.name}: server closed the connection")
|
|
73
|
+
line = line.strip()
|
|
74
|
+
if not line:
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
msg = json.loads(line)
|
|
78
|
+
except ValueError:
|
|
79
|
+
continue # stray non-JSON (some servers log to stdout — ignore)
|
|
80
|
+
if msg.get("id") == want_id:
|
|
81
|
+
if "error" in msg:
|
|
82
|
+
raise MCPError(f"{self.name}: {msg['error'].get('message', msg['error'])}")
|
|
83
|
+
return msg.get("result", {})
|
|
84
|
+
|
|
85
|
+
def _request(self, method: str, params: dict | None = None, timeout: float = 20.0) -> dict:
|
|
86
|
+
with self._lock:
|
|
87
|
+
self._id += 1
|
|
88
|
+
rid = self._id
|
|
89
|
+
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}})
|
|
90
|
+
return self._read_result(rid, timeout=timeout)
|
|
91
|
+
|
|
92
|
+
def _notify(self, method: str, params: dict | None = None) -> None:
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._send({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
|
95
|
+
|
|
96
|
+
# ── lifecycle ──────────────────────────────────────────────────────────
|
|
97
|
+
def start(self, timeout: float = 20.0) -> None:
|
|
98
|
+
env = {**os.environ, **self.env}
|
|
99
|
+
self.proc = subprocess.Popen(
|
|
100
|
+
[self.command, *self.args],
|
|
101
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
102
|
+
text=True, env=env, bufsize=1,
|
|
103
|
+
)
|
|
104
|
+
self._request("initialize", {
|
|
105
|
+
"protocolVersion": _PROTOCOL_VERSION,
|
|
106
|
+
"capabilities": {},
|
|
107
|
+
"clientInfo": {"name": "drydock", "version": "1"},
|
|
108
|
+
}, timeout=timeout)
|
|
109
|
+
self._notify("notifications/initialized")
|
|
110
|
+
result = self._request("tools/list", {}, timeout=timeout)
|
|
111
|
+
self.tools = result.get("tools", []) or []
|
|
112
|
+
|
|
113
|
+
def call(self, tool: str, arguments: dict, timeout: float = 60.0) -> str:
|
|
114
|
+
result = self._request("tools/call", {"name": tool, "arguments": arguments or {}},
|
|
115
|
+
timeout=timeout)
|
|
116
|
+
return _content_to_text(result)
|
|
117
|
+
|
|
118
|
+
def stop(self) -> None:
|
|
119
|
+
if self.proc:
|
|
120
|
+
try:
|
|
121
|
+
self.proc.terminate()
|
|
122
|
+
except Exception: # noqa: BLE001
|
|
123
|
+
pass
|
|
124
|
+
self.proc = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _content_to_text(result: dict) -> str:
|
|
128
|
+
"""Flatten an MCP tools/call result (content blocks) to text."""
|
|
129
|
+
if not isinstance(result, dict):
|
|
130
|
+
return str(result)
|
|
131
|
+
blocks = result.get("content")
|
|
132
|
+
if isinstance(blocks, list):
|
|
133
|
+
parts = []
|
|
134
|
+
for b in blocks:
|
|
135
|
+
if isinstance(b, dict):
|
|
136
|
+
if b.get("type") == "text":
|
|
137
|
+
parts.append(b.get("text", ""))
|
|
138
|
+
else:
|
|
139
|
+
parts.append(json.dumps(b))
|
|
140
|
+
text = "\n".join(p for p in parts if p)
|
|
141
|
+
else:
|
|
142
|
+
text = json.dumps(result)
|
|
143
|
+
if result.get("isError"):
|
|
144
|
+
text = f"[tool reported an error]\n{text}"
|
|
145
|
+
return text or "(empty result)"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ── config + connection ────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
def config_paths(cwd: str) -> list[Path]:
|
|
151
|
+
return [Path.home() / ".drydock" / "mcp.json", Path(cwd) / ".drydock" / "mcp.json"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def load_config(cwd: str) -> dict:
|
|
155
|
+
"""Merge mcpServers from user + project config (project overrides by name)."""
|
|
156
|
+
servers: dict[str, dict] = {}
|
|
157
|
+
for p in config_paths(cwd):
|
|
158
|
+
if not p.is_file():
|
|
159
|
+
continue
|
|
160
|
+
try:
|
|
161
|
+
data = json.loads(p.read_text("utf-8"))
|
|
162
|
+
except (OSError, ValueError):
|
|
163
|
+
continue
|
|
164
|
+
for name, spec in (data.get("mcpServers") or {}).items():
|
|
165
|
+
if isinstance(spec, dict) and spec.get("command"):
|
|
166
|
+
servers[name] = spec
|
|
167
|
+
return servers
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def connect_all(cwd: str, *, log=None) -> list[dict]:
|
|
171
|
+
"""Start every configured MCP server and return drydock tool schemas for
|
|
172
|
+
their tools (namespaced mcp__<server>__<tool>). Failures are skipped."""
|
|
173
|
+
_say = log or (lambda *_: None)
|
|
174
|
+
schemas: list[dict] = []
|
|
175
|
+
for name, spec in load_config(cwd).items():
|
|
176
|
+
if name in _SERVERS:
|
|
177
|
+
continue
|
|
178
|
+
srv = MCPServer(name, spec["command"], spec.get("args") or [], spec.get("env") or {})
|
|
179
|
+
try:
|
|
180
|
+
srv.start()
|
|
181
|
+
except (MCPError, OSError, Exception) as e: # noqa: BLE001 — never crash startup
|
|
182
|
+
_say(f"MCP server '{name}' failed to start: {e}")
|
|
183
|
+
srv.stop()
|
|
184
|
+
continue
|
|
185
|
+
_SERVERS[name] = srv
|
|
186
|
+
for t in srv.tools:
|
|
187
|
+
schemas.append({
|
|
188
|
+
"name": f"mcp__{name}__{t['name']}",
|
|
189
|
+
"description": (t.get("description") or f"{name} tool {t['name']}")[:1024],
|
|
190
|
+
"input_schema": t.get("inputSchema") or {"type": "object", "properties": {}},
|
|
191
|
+
})
|
|
192
|
+
_say(f"MCP server '{name}': {len(srv.tools)} tool(s)")
|
|
193
|
+
return schemas
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def call(server: str, tool: str, arguments: dict) -> str:
|
|
197
|
+
srv = _SERVERS.get(server)
|
|
198
|
+
if srv is None:
|
|
199
|
+
return f"MCP server '{server}' is not connected."
|
|
200
|
+
try:
|
|
201
|
+
return srv.call(tool, arguments)
|
|
202
|
+
except MCPError as e:
|
|
203
|
+
return f"MCP call failed: {e}"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def connected() -> dict[str, "MCPServer"]:
|
|
207
|
+
return dict(_SERVERS)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def shutdown() -> None:
|
|
211
|
+
for srv in _SERVERS.values():
|
|
212
|
+
srv.stop()
|
|
213
|
+
_SERVERS.clear()
|
|
@@ -161,6 +161,34 @@ SCHEMAS = [
|
|
|
161
161
|
"required": ["prompt"],
|
|
162
162
|
},
|
|
163
163
|
},
|
|
164
|
+
{
|
|
165
|
+
"name": "Dispatch",
|
|
166
|
+
"description": (
|
|
167
|
+
"Run SEVERAL read-only investigation sub-agents at once (in "
|
|
168
|
+
"parallel), each in its own fresh context with Read/Glob/Grep/Bash. "
|
|
169
|
+
"Use it to answer multiple INDEPENDENT questions concurrently, e.g. "
|
|
170
|
+
"'where is auth handled?', 'how does the DB layer work?', 'what tests "
|
|
171
|
+
"exist?'. Returns each agent's summary. They cannot write or recurse."
|
|
172
|
+
),
|
|
173
|
+
"input_schema": {
|
|
174
|
+
"type": "object",
|
|
175
|
+
"properties": {
|
|
176
|
+
"tasks": {
|
|
177
|
+
"type": "array",
|
|
178
|
+
"description": "Up to 6 independent investigation tasks.",
|
|
179
|
+
"items": {
|
|
180
|
+
"type": "object",
|
|
181
|
+
"properties": {
|
|
182
|
+
"prompt": {"type": "string", "description": "The self-contained task."},
|
|
183
|
+
"label": {"type": "string", "description": "Optional short label."},
|
|
184
|
+
},
|
|
185
|
+
"required": ["prompt"],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
"required": ["tasks"],
|
|
190
|
+
},
|
|
191
|
+
},
|
|
164
192
|
{
|
|
165
193
|
"name": "GitStatus",
|
|
166
194
|
"description": (
|
|
@@ -787,15 +815,10 @@ _SUBAGENT_SYSTEM = (
|
|
|
787
815
|
)
|
|
788
816
|
|
|
789
817
|
|
|
790
|
-
def
|
|
791
|
-
"""
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
write (no Write/Edit), so it can never corrupt the parent's work."""
|
|
795
|
-
prompt = (params.get("prompt") or params.get("description") or "").strip()
|
|
796
|
-
if not prompt:
|
|
797
|
-
return "Error: `task` needs a `prompt` describing what to investigate."
|
|
798
|
-
# Lazy import to avoid a tools <-> agent import cycle.
|
|
818
|
+
def _run_subagent(prompt: str, config: dict) -> str:
|
|
819
|
+
"""Run one read-only sub-agent to completion and return its final summary.
|
|
820
|
+
Shared by `task` (one) and `Dispatch` (many in parallel). Hard-capped; never
|
|
821
|
+
raises (a sub-agent must not crash the parent turn)."""
|
|
799
822
|
from drydock.agent import run as agent_run, AgentState, TurnDone
|
|
800
823
|
|
|
801
824
|
sub_state = AgentState()
|
|
@@ -806,6 +829,9 @@ def tool_task(params: dict, config: dict) -> str:
|
|
|
806
829
|
sub_config["max_tool_calls"] = 20
|
|
807
830
|
sub_config.pop("_todo", None) # the sub-agent keeps no checklist of its own
|
|
808
831
|
sub_config.pop("_plan_autocontinue", None)
|
|
832
|
+
# Own abort holder so parallel sub-agents don't clobber each other's (or the
|
|
833
|
+
# parent's) in-flight client/proc handles in the shared dict.
|
|
834
|
+
sub_config["_abort"] = {}
|
|
809
835
|
steps = 0
|
|
810
836
|
try:
|
|
811
837
|
for ev in agent_run(prompt, sub_state, sub_config, _SUBAGENT_SYSTEM):
|
|
@@ -819,6 +845,54 @@ def tool_task(params: dict, config: dict) -> str:
|
|
|
819
845
|
return f"[sub-agent finished {steps} step(s) with no summary]"
|
|
820
846
|
|
|
821
847
|
|
|
848
|
+
def tool_task(params: dict, config: dict) -> str:
|
|
849
|
+
"""Spawn a read-only sub-agent with a FRESH context for a focused
|
|
850
|
+
exploration task, returning only its final summary. Keeps big searches out
|
|
851
|
+
of the main agent's context. Cannot recurse (no `task` tool) and cannot
|
|
852
|
+
write (no Write/Edit), so it can never corrupt the parent's work."""
|
|
853
|
+
prompt = (params.get("prompt") or params.get("description") or "").strip()
|
|
854
|
+
if not prompt:
|
|
855
|
+
return "Error: `task` needs a `prompt` describing what to investigate."
|
|
856
|
+
return _run_subagent(prompt, config)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def tool_dispatch(params: dict, config: dict) -> str:
|
|
860
|
+
"""Run SEVERAL read-only sub-agents concurrently and return all summaries.
|
|
861
|
+
Each gets its own fresh context + Read/Glob/Grep/Bash (no recursion, no
|
|
862
|
+
writes). Use it to investigate independent questions at once."""
|
|
863
|
+
import concurrent.futures
|
|
864
|
+
|
|
865
|
+
raw = params.get("tasks") or params.get("agents") or []
|
|
866
|
+
if isinstance(raw, dict):
|
|
867
|
+
raw = [raw]
|
|
868
|
+
norm: list[dict] = []
|
|
869
|
+
for t in raw:
|
|
870
|
+
if isinstance(t, str) and t.strip():
|
|
871
|
+
norm.append({"prompt": t.strip(), "label": ""})
|
|
872
|
+
elif isinstance(t, dict):
|
|
873
|
+
p = (t.get("prompt") or t.get("description") or "").strip()
|
|
874
|
+
if p:
|
|
875
|
+
norm.append({"prompt": p, "label": (t.get("label") or t.get("description") or "").strip()})
|
|
876
|
+
if not norm:
|
|
877
|
+
return "Error: `Dispatch` needs a `tasks` list, each item a prompt (string) or {prompt, label}."
|
|
878
|
+
norm = norm[:6] # cap fan-out
|
|
879
|
+
|
|
880
|
+
results: list[str] = [""] * len(norm)
|
|
881
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=min(4, len(norm))) as ex:
|
|
882
|
+
futs = {ex.submit(_run_subagent, t["prompt"], config): i for i, t in enumerate(norm)}
|
|
883
|
+
for fut in concurrent.futures.as_completed(futs):
|
|
884
|
+
i = futs[fut]
|
|
885
|
+
try:
|
|
886
|
+
results[i] = fut.result()
|
|
887
|
+
except Exception as e: # noqa: BLE001
|
|
888
|
+
results[i] = f"[agent error: {e}]"
|
|
889
|
+
parts = [f"Dispatched {len(norm)} sub-agent(s) in parallel:"]
|
|
890
|
+
for i, t in enumerate(norm):
|
|
891
|
+
label = t["label"] or f"agent {i + 1}"
|
|
892
|
+
parts.append(f"\n=== {label} ===\n{results[i]}")
|
|
893
|
+
return "\n".join(parts)
|
|
894
|
+
|
|
895
|
+
|
|
822
896
|
def _git_cwd(config: dict) -> str:
|
|
823
897
|
return config.get("cwd") or os.getcwd()
|
|
824
898
|
|
|
@@ -940,6 +1014,7 @@ _TOOLS = [
|
|
|
940
1014
|
("Grep", tool_grep, True),
|
|
941
1015
|
("todo", tool_todo, False),
|
|
942
1016
|
("task", tool_task, True),
|
|
1017
|
+
("Dispatch", tool_dispatch, True),
|
|
943
1018
|
("Knowledge", tool_knowledge, True),
|
|
944
1019
|
("WebSearch", tool_websearch, True),
|
|
945
1020
|
("WebFetch", tool_webfetch, True),
|
|
@@ -955,7 +1030,8 @@ def register_all():
|
|
|
955
1030
|
func = {
|
|
956
1031
|
"Read": tool_read, "Write": tool_write, "Edit": tool_edit,
|
|
957
1032
|
"Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
|
|
958
|
-
"todo": tool_todo, "task": tool_task, "
|
|
1033
|
+
"todo": tool_todo, "task": tool_task, "Dispatch": tool_dispatch,
|
|
1034
|
+
"Knowledge": tool_knowledge,
|
|
959
1035
|
"WebSearch": tool_websearch, "WebFetch": tool_webfetch,
|
|
960
1036
|
"GitStatus": tool_gitstatus, "GitDiff": tool_gitdiff,
|
|
961
1037
|
"GitLog": tool_gitlog, "GitCommit": tool_gitcommit,
|
|
@@ -963,8 +1039,8 @@ def register_all():
|
|
|
963
1039
|
# Read-only w.r.t. the parent's files (GitStatus/Diff/Log inspect only;
|
|
964
1040
|
# GitCommit writes a local, reversible commit).
|
|
965
1041
|
read_only = name in (
|
|
966
|
-
"Read", "Glob", "Grep", "task", "
|
|
967
|
-
"GitStatus", "GitDiff", "GitLog",
|
|
1042
|
+
"Read", "Glob", "Grep", "task", "Dispatch", "Knowledge",
|
|
1043
|
+
"WebSearch", "WebFetch", "GitStatus", "GitDiff", "GitLog",
|
|
968
1044
|
)
|
|
969
1045
|
register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
|
|
970
1046
|
|
|
@@ -494,10 +494,13 @@ class DrydockApp(App):
|
|
|
494
494
|
" /graphrag build <path> · /graphrag status · /graphrag clear\n"
|
|
495
495
|
" /skills list your reusable /<name> skills\n"
|
|
496
496
|
" /loop /loop <count> <prompt> — repeat a prompt (Esc stops)\n"
|
|
497
|
+
" /mcp list connected MCP servers and their tools\n"
|
|
497
498
|
" /clear reset the conversation\n"
|
|
498
499
|
" /quit exit\n"
|
|
499
500
|
"Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
|
|
500
501
|
)
|
|
502
|
+
elif cmd == "/mcp":
|
|
503
|
+
self._cmd_mcp()
|
|
501
504
|
elif cmd == "/loop":
|
|
502
505
|
self._cmd_loop(arg)
|
|
503
506
|
elif cmd == "/skills":
|
|
@@ -507,6 +510,27 @@ class DrydockApp(App):
|
|
|
507
510
|
else:
|
|
508
511
|
self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
|
|
509
512
|
|
|
513
|
+
def _cmd_mcp(self) -> None:
|
|
514
|
+
"""List connected MCP servers and the tools they expose."""
|
|
515
|
+
from drydock import mcp
|
|
516
|
+
|
|
517
|
+
servers = mcp.connected()
|
|
518
|
+
lines: list[str] = []
|
|
519
|
+
if not servers:
|
|
520
|
+
lines.append(
|
|
521
|
+
"No MCP servers connected. Configure them in ~/.drydock/mcp.json "
|
|
522
|
+
"(or <project>/.drydock/mcp.json) under \"mcpServers\", then restart."
|
|
523
|
+
)
|
|
524
|
+
else:
|
|
525
|
+
lines.append(f"MCP servers ({len(servers)} connected):")
|
|
526
|
+
for name, srv in servers.items():
|
|
527
|
+
tools = ", ".join(t["name"] for t in srv.tools) or "(no tools)"
|
|
528
|
+
lines.append(f" • {name}: {tools}")
|
|
529
|
+
lines.append("Call them as mcp__<server>__<tool> (the model does this automatically).")
|
|
530
|
+
for msg in self.config.get("mcp_log") or []:
|
|
531
|
+
lines.append(f" · {msg}")
|
|
532
|
+
self._info("\n".join(lines))
|
|
533
|
+
|
|
510
534
|
def _cmd_loop(self, arg: str) -> None:
|
|
511
535
|
"""/loop <count> <prompt> — run <prompt> up to <count> times (1–50),
|
|
512
536
|
re-submitting after each turn finishes. Esc (or /stop) ends the loop."""
|
|
@@ -153,7 +153,7 @@ class PromptHistory:
|
|
|
153
153
|
# completes the prefix.
|
|
154
154
|
SLASH_COMMANDS = [
|
|
155
155
|
"/help", "/model", "/cwd", "/undo", "/back", "/stop", "/status",
|
|
156
|
-
"/compact", "/graphrag", "/skills", "/loop", "/clear", "/quit",
|
|
156
|
+
"/compact", "/graphrag", "/skills", "/loop", "/mcp", "/clear", "/quit",
|
|
157
157
|
]
|
|
158
158
|
|
|
159
159
|
|
|
@@ -14,6 +14,7 @@ drydock/gittools.py
|
|
|
14
14
|
drydock/graphrag.py
|
|
15
15
|
drydock/guards.py
|
|
16
16
|
drydock/loop_detect.py
|
|
17
|
+
drydock/mcp.py
|
|
17
18
|
drydock/providers.py
|
|
18
19
|
drydock/skills.py
|
|
19
20
|
drydock/tool_registry.py
|
|
@@ -43,6 +44,7 @@ tests/test_config.py
|
|
|
43
44
|
tests/test_config_migration.py
|
|
44
45
|
tests/test_context_limit_config.py
|
|
45
46
|
tests/test_detect.py
|
|
47
|
+
tests/test_dispatch.py
|
|
46
48
|
tests/test_empty_response.py
|
|
47
49
|
tests/test_failure_loop.py
|
|
48
50
|
tests/test_first_run_setup.py
|
|
@@ -52,6 +54,7 @@ tests/test_guards_and_tools.py
|
|
|
52
54
|
tests/test_hallucinated_tools.py
|
|
53
55
|
tests/test_leaked_tool_call.py
|
|
54
56
|
tests/test_loop_detect.py
|
|
57
|
+
tests/test_mcp.py
|
|
55
58
|
tests/test_oneshot_unreachable.py
|
|
56
59
|
tests/test_plan_autocontinue.py
|
|
57
60
|
tests/test_providers_unreachable.py
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# PyPI distribution name is drydock-cli (the established install name, continued
|
|
8
8
|
# from the retired v2 fork); the import package + CLI command stay `drydock`.
|
|
9
9
|
name = "drydock-cli"
|
|
10
|
-
version = "3.0.
|
|
10
|
+
version = "3.0.55"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Multi-agent: the Dispatch tool fans out several read-only sub-agents in
|
|
2
|
+
parallel and returns all summaries. The sub-agent runner is mocked so tests are
|
|
3
|
+
deterministic and don't need the LLM."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import drydock.tools as T
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_dispatch_runs_each_task_and_labels(monkeypatch):
|
|
12
|
+
monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"R:{p}")
|
|
13
|
+
out = T.tool_dispatch({"tasks": [
|
|
14
|
+
{"prompt": "find auth", "label": "auth"},
|
|
15
|
+
{"prompt": "find db"},
|
|
16
|
+
]}, {})
|
|
17
|
+
assert "=== auth ===" in out and "R:find auth" in out
|
|
18
|
+
assert "=== agent 2 ===" in out and "R:find db" in out
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_dispatch_accepts_string_tasks(monkeypatch):
|
|
22
|
+
monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"R:{p}")
|
|
23
|
+
out = T.tool_dispatch({"tasks": ["a", "b", "c"]}, {})
|
|
24
|
+
assert out.count("R:") == 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_dispatch_needs_tasks():
|
|
28
|
+
assert "needs a `tasks`" in T.tool_dispatch({"tasks": []}, {})
|
|
29
|
+
assert "needs a `tasks`" in T.tool_dispatch({}, {})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_dispatch_caps_fanout(monkeypatch):
|
|
33
|
+
monkeypatch.setattr(T, "_run_subagent", lambda p, c: "x")
|
|
34
|
+
out = T.tool_dispatch({"tasks": [f"t{i}" for i in range(20)]}, {})
|
|
35
|
+
assert "Dispatched 6 sub-agent" in out # capped at 6
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_dispatch_isolates_subagent_errors(monkeypatch):
|
|
39
|
+
def runner(p, c):
|
|
40
|
+
if "boom" in p:
|
|
41
|
+
raise RuntimeError("kaboom")
|
|
42
|
+
return f"R:{p}"
|
|
43
|
+
monkeypatch.setattr(T, "_run_subagent", runner)
|
|
44
|
+
out = T.tool_dispatch({"tasks": ["ok", "boom"]}, {})
|
|
45
|
+
assert "R:ok" in out and "agent error" in out # one fails, the other survives
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_dispatch_runs_concurrently(monkeypatch):
|
|
49
|
+
# 4 tasks that each sleep 0.2s should finish in well under 0.8s (serial time)
|
|
50
|
+
def slow(p, c):
|
|
51
|
+
time.sleep(0.2)
|
|
52
|
+
return "done"
|
|
53
|
+
monkeypatch.setattr(T, "_run_subagent", slow)
|
|
54
|
+
t0 = time.monotonic()
|
|
55
|
+
T.tool_dispatch({"tasks": ["a", "b", "c", "d"]}, {})
|
|
56
|
+
assert time.monotonic() - t0 < 0.6 # parallel, not 0.8s serial
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_task_still_works_via_shared_runner(monkeypatch):
|
|
60
|
+
monkeypatch.setattr(T, "_run_subagent", lambda p, c: f"summary:{p}")
|
|
61
|
+
assert T.tool_task({"prompt": "explore X"}, {}) == "summary:explore X"
|
|
62
|
+
assert "needs a `prompt`" in T.tool_task({}, {})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""MCP client: connect to a stdio MCP server, list + call its tools. Uses a real
|
|
2
|
+
(mock) server subprocess so the JSON-RPC handshake is exercised end to end."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from drydock import mcp
|
|
10
|
+
|
|
11
|
+
_SERVER = str(Path(__file__).parent / "fixtures" / "mock_mcp_server.py")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _write_cfg(cwd: Path):
|
|
15
|
+
d = cwd / ".drydock"
|
|
16
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
(d / "mcp.json").write_text(json.dumps({
|
|
18
|
+
"mcpServers": {"mock": {"command": sys.executable, "args": [_SERVER]}}
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def teardown_function(_):
|
|
23
|
+
mcp.shutdown()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_connect_lists_tools(tmp_path):
|
|
27
|
+
_write_cfg(tmp_path)
|
|
28
|
+
schemas = mcp.connect_all(str(tmp_path))
|
|
29
|
+
names = [s["name"] for s in schemas]
|
|
30
|
+
assert "mcp__mock__echo" in names
|
|
31
|
+
assert mcp.connected()["mock"].tools[0]["name"] == "echo"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_call_tool_round_trips(tmp_path):
|
|
35
|
+
_write_cfg(tmp_path)
|
|
36
|
+
mcp.connect_all(str(tmp_path))
|
|
37
|
+
out = mcp.call("mock", "echo", {"text": "hello mcp"})
|
|
38
|
+
assert out == "echo: hello mcp"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_call_unknown_server_is_graceful(tmp_path):
|
|
42
|
+
assert "not connected" in mcp.call("nope", "echo", {"text": "x"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_missing_config_is_noop(tmp_path):
|
|
46
|
+
assert mcp.connect_all(str(tmp_path)) == []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_bad_server_is_skipped(tmp_path):
|
|
50
|
+
d = tmp_path / ".drydock"; d.mkdir(parents=True)
|
|
51
|
+
(d / "mcp.json").write_text(json.dumps({
|
|
52
|
+
"mcpServers": {"broken": {"command": "this_command_does_not_exist_xyz", "args": []}}
|
|
53
|
+
}))
|
|
54
|
+
logs = []
|
|
55
|
+
schemas = mcp.connect_all(str(tmp_path), log=logs.append)
|
|
56
|
+
assert schemas == []
|
|
57
|
+
assert any("failed to start" in m for m in logs)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|