drydock-cli 3.0.54__tar.gz → 3.0.56__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.
Files changed (75) hide show
  1. {drydock_cli-3.0.54/drydock_cli.egg-info → drydock_cli-3.0.56}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/cli.py +32 -0
  3. drydock_cli-3.0.56/drydock/mcp.py +213 -0
  4. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/tools/__init__.py +54 -5
  5. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/tui/app.py +24 -0
  6. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/tui/widgets.py +1 -1
  7. {drydock_cli-3.0.54 → drydock_cli-3.0.56/drydock_cli.egg-info}/PKG-INFO +1 -1
  8. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock_cli.egg-info/SOURCES.txt +3 -0
  9. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/pyproject.toml +1 -1
  10. drydock_cli-3.0.56/tests/test_mcp.py +57 -0
  11. drydock_cli-3.0.56/tests/test_read_index.py +52 -0
  12. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/LICENSE +0 -0
  13. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/NOTICE +0 -0
  14. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/README.md +0 -0
  15. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/__init__.py +0 -0
  16. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/__main__.py +0 -0
  17. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/agent.py +0 -0
  18. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/bash_safety.py +0 -0
  19. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/compaction.py +0 -0
  20. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/config.py +0 -0
  21. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/detect.py +0 -0
  22. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/gittools.py +0 -0
  23. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/graphrag.py +0 -0
  24. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/guards.py +0 -0
  25. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/loop_detect.py +0 -0
  26. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/providers.py +0 -0
  27. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/skills.py +0 -0
  28. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/tool_registry.py +0 -0
  29. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/tui/__init__.py +0 -0
  30. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/tui/approval.py +0 -0
  31. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/tui/messages.py +0 -0
  32. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/tuning.py +0 -0
  33. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock/web.py +0 -0
  34. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock_cli.egg-info/dependency_links.txt +0 -0
  35. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock_cli.egg-info/entry_points.txt +0 -0
  36. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock_cli.egg-info/requires.txt +0 -0
  37. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/drydock_cli.egg-info/top_level.txt +0 -0
  38. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/setup.cfg +0 -0
  39. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_approval.py +0 -0
  40. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_back_command.py +0 -0
  41. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_bash_process_group.py +0 -0
  42. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_bash_safety.py +0 -0
  43. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_bash_timeout_network.py +0 -0
  44. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_cli_agents.py +0 -0
  45. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_compact_command.py +0 -0
  46. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_compaction.py +0 -0
  47. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_config.py +0 -0
  48. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_config_migration.py +0 -0
  49. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_context_limit_config.py +0 -0
  50. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_detect.py +0 -0
  51. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_dispatch.py +0 -0
  52. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_empty_response.py +0 -0
  53. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_failure_loop.py +0 -0
  54. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_first_run_setup.py +0 -0
  55. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_gittools.py +0 -0
  56. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_graphrag.py +0 -0
  57. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_guards_and_tools.py +0 -0
  58. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_hallucinated_tools.py +0 -0
  59. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_leaked_tool_call.py +0 -0
  60. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_loop_detect.py +0 -0
  61. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_oneshot_unreachable.py +0 -0
  62. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_plan_autocontinue.py +0 -0
  63. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_providers_unreachable.py +0 -0
  64. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_runaway_repetition.py +0 -0
  65. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_skills.py +0 -0
  66. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_stop.py +0 -0
  67. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_streaming_newlines.py +0 -0
  68. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_subagent.py +0 -0
  69. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_todo.py +0 -0
  70. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_tool_arg_parsing.py +0 -0
  71. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_tools_undo.py +0 -0
  72. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_tui.py +0 -0
  73. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_tuning.py +0 -0
  74. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_vision_input.py +0 -0
  75. {drydock_cli-3.0.54 → drydock_cli-3.0.56}/tests/test_web_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.54
3
+ Version: 3.0.56
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.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()
@@ -41,7 +41,7 @@ def _resolve_path(path: str, config: dict) -> str:
41
41
  SCHEMAS = [
42
42
  {
43
43
  "name": "Read",
44
- "description": "Read a file. Returns content with line numbers. Use limit/offset for large files.",
44
+ "description": "Read a file. Returns content with line numbers. Use limit/offset for large files. A very large file with no limit returns a STRUCTURE INDEX (key symbols + line numbers) instead of the whole file — then Read the ranges you need with offset/limit.",
45
45
  "input_schema": {
46
46
  "type": "object",
47
47
  "properties": {
@@ -303,18 +303,67 @@ SCHEMAS = [
303
303
 
304
304
  # ── Tool implementations ──────────────────────────────────────────────────
305
305
 
306
+ # Above this many lines, a Read with no explicit window returns a structural
307
+ # INDEX (key symbols + line numbers) instead of dumping the file — semantic
308
+ # chunking so a huge file can't blow the context window. The model then Reads
309
+ # the ranges it needs with offset/limit.
310
+ _BIG_FILE_LINES = 1500
311
+
312
+ # Structural anchors: a definition → a node the model can jump to. Code anchors
313
+ # apply to every file; markdown headers (anchored at column 0 so indented code
314
+ # COMMENTS aren't mistaken for headings) apply only to doc files.
315
+ _CODE_RE = re.compile(
316
+ r"^\s*("
317
+ r"(?:async\s+)?def\s+\w+" # python functions
318
+ r"|class\s+\w+" # python/js/etc classes
319
+ r"|(?:export\s+)?(?:async\s+)?function\s+\w+" # js/ts functions
320
+ r"|(?:export\s+)?(?:const|let|var)\s+\w+\s*=\s*(?:async\s*)?\(" # js arrow fns
321
+ r"|func\s+\w+" # go
322
+ r"|fn\s+\w+" # rust
323
+ r")"
324
+ )
325
+ _MD_RE = re.compile(r"^#{1,6}\s+\S") # markdown headers, column 0 only
326
+ _DOC_EXT = {".md", ".markdown", ".rst", ".txt", ".org"}
327
+
328
+
329
+ def _file_index(lines: list[str], fp: str) -> str:
330
+ """A compact, line-numbered index of a large file's structure."""
331
+ is_doc = Path(fp).suffix.lower() in _DOC_EXT
332
+ anchors = [
333
+ f" L{i + 1}: {ln.rstrip()[:100]}"
334
+ for i, ln in enumerate(lines)
335
+ if _CODE_RE.match(ln) or (is_doc and _MD_RE.match(ln))
336
+ ]
337
+ head = (
338
+ f"{fp} is large ({len(lines)} lines) — showing a STRUCTURE INDEX instead "
339
+ f"of the whole file (to save context). Read a specific section with "
340
+ f"offset/limit (e.g. {{\"file_path\": ..., \"offset\": <line-1>, "
341
+ f"\"limit\": 120}}).\n"
342
+ )
343
+ if not anchors:
344
+ return head + "(no def/class/header anchors found — read ranges with offset/limit.)"
345
+ body = "\n".join(anchors[:400])
346
+ if len(anchors) > 400:
347
+ body += f"\n [... {len(anchors) - 400} more anchors ...]"
348
+ return f"{head}\nKey locations ({len(anchors)}):\n{body}"
349
+
350
+
306
351
  def tool_read(params: dict, config: dict) -> str:
307
352
  fp = _resolve_path(params["file_path"], config)
308
- limit = params.get("limit", 2000)
353
+ limit = params.get("limit") # None = caller didn't specify a window
309
354
  offset = params.get("offset", 0)
310
355
  try:
311
356
  with open(fp, "r", encoding="utf-8", errors="replace") as f:
312
357
  lines = f.readlines()
313
- selected = lines[offset:offset + limit]
358
+ # Huge file, no explicit window → index it instead of dumping it.
359
+ if limit is None and offset == 0 and len(lines) > _BIG_FILE_LINES:
360
+ return _file_index(lines, fp)
361
+ eff_limit = 2000 if limit is None else limit
362
+ selected = lines[offset:offset + eff_limit]
314
363
  numbered = [f"{i + offset + 1}\t{line.rstrip()}" for i, line in enumerate(selected)]
315
364
  result = "\n".join(numbered)
316
- if len(lines) > offset + limit:
317
- result += f"\n[... {len(lines) - offset - limit} more lines]"
365
+ if len(lines) > offset + eff_limit:
366
+ result += f"\n[... {len(lines) - offset - eff_limit} more lines]"
318
367
  return result or "(empty file)"
319
368
  except FileNotFoundError:
320
369
  return f"Error: file not found: {fp}"
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.54
3
+ Version: 3.0.56
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -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
@@ -53,9 +54,11 @@ tests/test_guards_and_tools.py
53
54
  tests/test_hallucinated_tools.py
54
55
  tests/test_leaked_tool_call.py
55
56
  tests/test_loop_detect.py
57
+ tests/test_mcp.py
56
58
  tests/test_oneshot_unreachable.py
57
59
  tests/test_plan_autocontinue.py
58
60
  tests/test_providers_unreachable.py
61
+ tests/test_read_index.py
59
62
  tests/test_runaway_repetition.py
60
63
  tests/test_skills.py
61
64
  tests/test_stop.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.54"
10
+ version = "3.0.56"
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,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)
@@ -0,0 +1,52 @@
1
+ """Semantic chunking: Read returns a STRUCTURE INDEX for a huge file (no
2
+ explicit window) instead of dumping it, so it can't blow the context window."""
3
+ from __future__ import annotations
4
+
5
+ from drydock.tools import tool_read
6
+
7
+
8
+ def _big_py(tmp_path, n=2000):
9
+ lines = []
10
+ for i in range(n):
11
+ if i % 400 == 0:
12
+ lines.append(f"def func_{i}(x):")
13
+ elif i % 400 == 1:
14
+ lines.append(" return x")
15
+ else:
16
+ lines.append(f" # comment {i}")
17
+ (tmp_path / "big.py").write_text("\n".join(lines))
18
+ return {"cwd": str(tmp_path)}
19
+
20
+
21
+ def test_huge_file_no_window_returns_index(tmp_path):
22
+ cfg = _big_py(tmp_path)
23
+ out = tool_read({"file_path": "big.py"}, cfg)
24
+ assert "STRUCTURE INDEX" in out
25
+ assert "def func_0" in out and "def func_400" in out
26
+ assert "# comment" not in out # comments are not anchors
27
+
28
+
29
+ def test_index_is_small_not_a_dump(tmp_path):
30
+ cfg = _big_py(tmp_path)
31
+ out = tool_read({"file_path": "big.py"}, cfg)
32
+ assert len(out.splitlines()) < 50 # an index, not 2000 lines
33
+
34
+
35
+ def test_explicit_window_still_reads_slice(tmp_path):
36
+ cfg = _big_py(tmp_path)
37
+ out = tool_read({"file_path": "big.py", "offset": 0, "limit": 3}, cfg)
38
+ assert "STRUCTURE INDEX" not in out
39
+ assert out.startswith("1\tdef func_0")
40
+
41
+
42
+ def test_small_file_reads_fully(tmp_path):
43
+ (tmp_path / "s.py").write_text("def a():\n return 1\n")
44
+ out = tool_read({"file_path": "s.py"}, {"cwd": str(tmp_path)})
45
+ assert "STRUCTURE INDEX" not in out and "def a()" in out
46
+
47
+
48
+ def test_markdown_headers_indexed(tmp_path):
49
+ body = "\n".join(["# Title"] + [f"para {i}" for i in range(2000)] + ["## Section B"])
50
+ (tmp_path / "doc.md").write_text(body)
51
+ out = tool_read({"file_path": "doc.md"}, {"cwd": str(tmp_path)})
52
+ assert "STRUCTURE INDEX" in out and "# Title" in out and "## Section B" in out
File without changes
File without changes
File without changes
File without changes