dev-link-mcp 0.1.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 (74) hide show
  1. dev_link_mcp/__init__.py +5 -0
  2. dev_link_mcp/__main__.py +3 -0
  3. dev_link_mcp/app.py +308 -0
  4. dev_link_mcp/audit.py +625 -0
  5. dev_link_mcp/browse.py +260 -0
  6. dev_link_mcp/cli/__init__.py +5 -0
  7. dev_link_mcp/cli/common.py +75 -0
  8. dev_link_mcp/cli/config_cmd.py +88 -0
  9. dev_link_mcp/cli/doctor.py +184 -0
  10. dev_link_mcp/cli/info.py +102 -0
  11. dev_link_mcp/cli/instance.py +232 -0
  12. dev_link_mcp/cli/main.py +177 -0
  13. dev_link_mcp/cli/serve.py +303 -0
  14. dev_link_mcp/cli/tools_cmd.py +92 -0
  15. dev_link_mcp/cli/tunnel.py +134 -0
  16. dev_link_mcp/config/__init__.py +31 -0
  17. dev_link_mcp/config/loader.py +248 -0
  18. dev_link_mcp/config/models.py +291 -0
  19. dev_link_mcp/config/schema.py +41 -0
  20. dev_link_mcp/dashboard/__init__.py +0 -0
  21. dev_link_mcp/dashboard/css/app.css +326 -0
  22. dev_link_mcp/dashboard/index.html +181 -0
  23. dev_link_mcp/dashboard/js/activity.js +389 -0
  24. dev_link_mcp/dashboard/js/api.js +39 -0
  25. dev_link_mcp/dashboard/js/dom.js +36 -0
  26. dev_link_mcp/dashboard/js/drawer.js +195 -0
  27. dev_link_mcp/dashboard/js/files.js +273 -0
  28. dev_link_mcp/dashboard/js/fmt.js +33 -0
  29. dev_link_mcp/dashboard/js/live.js +56 -0
  30. dev_link_mcp/dashboard/js/main.js +100 -0
  31. dev_link_mcp/dashboard/js/processes.js +214 -0
  32. dev_link_mcp/dashboard/js/stats.js +200 -0
  33. dev_link_mcp/dashboard/js/tasks.js +63 -0
  34. dev_link_mcp/dashboard/js/tools.js +152 -0
  35. dev_link_mcp/dashboard/js/ui.js +70 -0
  36. dev_link_mcp/dashboard/js/vlist.js +110 -0
  37. dev_link_mcp/live.py +41 -0
  38. dev_link_mcp/middleware.py +110 -0
  39. dev_link_mcp/orphans.py +96 -0
  40. dev_link_mcp/patterns.py +232 -0
  41. dev_link_mcp/private.py +21 -0
  42. dev_link_mcp/processes.py +467 -0
  43. dev_link_mcp/py.typed +0 -0
  44. dev_link_mcp/runtime.py +121 -0
  45. dev_link_mcp/sandbox/__init__.py +454 -0
  46. dev_link_mcp/sandbox/backends/__init__.py +63 -0
  47. dev_link_mcp/sandbox/backends/bubblewrap.py +235 -0
  48. dev_link_mcp/sandbox/backends/none.py +21 -0
  49. dev_link_mcp/sandbox/limits.py +162 -0
  50. dev_link_mcp/tools/__init__.py +394 -0
  51. dev_link_mcp/tools/base.py +52 -0
  52. dev_link_mcp/tools/browser.py +1099 -0
  53. dev_link_mcp/tools/checkpoints.py +387 -0
  54. dev_link_mcp/tools/code.py +488 -0
  55. dev_link_mcp/tools/common.py +22 -0
  56. dev_link_mcp/tools/data.py +444 -0
  57. dev_link_mcp/tools/docker.py +1072 -0
  58. dev_link_mcp/tools/files.py +813 -0
  59. dev_link_mcp/tools/http.py +500 -0
  60. dev_link_mcp/tools/processes.py +269 -0
  61. dev_link_mcp/tools/project/__init__.py +227 -0
  62. dev_link_mcp/tools/project/detect.py +775 -0
  63. dev_link_mcp/tools/project/parsers.py +400 -0
  64. dev_link_mcp/tools/search.py +443 -0
  65. dev_link_mcp/tools/shell.py +134 -0
  66. dev_link_mcp/tools/system.py +123 -0
  67. dev_link_mcp/tools/tasks.py +347 -0
  68. dev_link_mcp/web.py +375 -0
  69. dev_link_mcp/workspace.py +320 -0
  70. dev_link_mcp-0.1.0.dist-info/METADATA +291 -0
  71. dev_link_mcp-0.1.0.dist-info/RECORD +74 -0
  72. dev_link_mcp-0.1.0.dist-info/WHEEL +4 -0
  73. dev_link_mcp-0.1.0.dist-info/entry_points.txt +4 -0
  74. dev_link_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,5 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("dev-link-mcp")
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from dev_link_mcp.cli import main
2
+
3
+ raise SystemExit(main())
dev_link_mcp/app.py ADDED
@@ -0,0 +1,308 @@
1
+ """Server assembly: MCP tools, auth, the dashboard and the HTTP server loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import hmac
8
+ import json
9
+ import logging
10
+ import os
11
+ import sys
12
+ from collections.abc import Callable
13
+ from pathlib import Path
14
+ from types import FrameType
15
+ from typing import Any
16
+ from urllib.parse import urlsplit
17
+
18
+ import uvicorn
19
+ from mcp.server.mcpserver import MCPServer
20
+ from mcp.server.transport_security import TransportSecuritySettings
21
+ from starlette.applications import Starlette
22
+ from starlette.responses import JSONResponse
23
+ from starlette.types import ASGIApp, Receive, Scope, Send
24
+
25
+ from dev_link_mcp import __version__
26
+ from dev_link_mcp.audit import redact
27
+ from dev_link_mcp.config import LOOPBACK_HOSTS, Config
28
+ from dev_link_mcp.middleware import AuditMiddleware
29
+ from dev_link_mcp.private import open_private
30
+ from dev_link_mcp.runtime import Runtime
31
+ from dev_link_mcp.tools import Toolset, register_groups
32
+ from dev_link_mcp.web import add_dashboard_routes
33
+
34
+ log = logging.getLogger(__name__)
35
+
36
+ INSTRUCTIONS = """\
37
+ dev-link-mcp is a development environment for one workspace folder. You may run any command
38
+ and change anything in the workspace.
39
+
40
+ - Call workspace_info first: it shows the sandbox mode, network access, limits and
41
+ installed toolchains.
42
+ - Prefer the dedicated tools. Before reaching for the shell, check whether one of the
43
+ offered tools already does the job, and use it if so. The dedicated tools return
44
+ structured, compact results, check their input, and show up as clear, separate steps on
45
+ the user's dashboard. A shell command that does the same thing hides that detail.
46
+ - Use run_command as the fallback, for work that no offered tool covers. The set of tools
47
+ depends on the server's configuration, so a tool you expect may be missing; in that case
48
+ the shell is the right choice.
49
+ - Use start_process for servers, watchers and anything long-running, and process_output to
50
+ read their output. The browser_* tools load pages in Chromium and save screenshots under
51
+ .dev-link/artifacts.
52
+ - Relative paths are resolved from the workspace root. Commands normally run in a bubblewrap
53
+ sandbox where the workspace is writable and the rest of the host is read-only or hidden.
54
+ - Work on the current branch or in a git worktree, whichever suits the task.
55
+ checkpoint_create and checkpoint_restore give a cheap undo for risky changes.
56
+ Every call is logged and shown to the user on a live dashboard.
57
+ """
58
+
59
+
60
+ class BearerAuth:
61
+ """ASGI guard requiring `Authorization: Bearer <token>` on the MCP endpoint."""
62
+
63
+ def __init__(self, app: ASGIApp, token: str, mcp_path: str):
64
+ self.app = app
65
+ self.expected = f"Bearer {token}".encode()
66
+ self.mcp_path = mcp_path
67
+
68
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
69
+ if scope["type"] == "http" and scope["path"].rstrip("/") == self.mcp_path:
70
+ supplied = dict(scope.get("headers") or []).get(b"authorization", b"")
71
+ if not hmac.compare_digest(supplied, self.expected):
72
+ response = JSONResponse(
73
+ {"error": "missing or invalid bearer token"},
74
+ status_code=401,
75
+ headers={"WWW-Authenticate": "Bearer"},
76
+ )
77
+ await response(scope, receive, send)
78
+ return
79
+ await self.app(scope, receive, send)
80
+
81
+
82
+ async def assemble(config: Config) -> tuple[MCPServer, Runtime, Toolset]:
83
+ """Create the runtime and an MCP server with every enabled tool group registered.
84
+
85
+ Adds no routes and records nothing in the audit log, so it also serves listings such as
86
+ `dev-link tools`. The caller owns the result: shut the toolset down and close the audit
87
+ store when done. Raises SandboxUnavailable and ToolGroupError like build(); the runtime is
88
+ closed again in that case.
89
+ """
90
+ rt = Runtime.create(config)
91
+ mcp: MCPServer = MCPServer(
92
+ "dev-link-mcp",
93
+ title="dev-link",
94
+ description="Sandboxed development environment for one workspace folder",
95
+ instructions=INSTRUCTIONS,
96
+ version=__version__,
97
+ middleware=[AuditMiddleware(rt.audit)],
98
+ log_level=config.log_level,
99
+ )
100
+ try:
101
+ toolset = await register_groups(mcp, rt)
102
+ except BaseException:
103
+ rt.audit.close()
104
+ raise
105
+ return mcp, rt, toolset
106
+
107
+
108
+ async def build(config: Config) -> tuple[Starlette, Runtime, Toolset]:
109
+ """Create the runtime, register tools and routes. Returns (ASGI app, runtime, tool groups).
110
+
111
+ Raises SandboxUnavailable when the sandbox cannot run here, and ToolGroupError for invalid
112
+ tool group settings (see register_groups); the runtime is closed again in that case.
113
+ Records a server start event in the audit log.
114
+ """
115
+ mcp, rt, toolset = await assemble(config)
116
+ add_dashboard_routes(mcp, rt, toolset)
117
+
118
+ security: TransportSecuritySettings | None = None
119
+ extra_hosts = list(config.server.allowed_hosts)
120
+ if config.server.public_url:
121
+ extra_hosts.append(urlsplit(config.server.public_url).netloc)
122
+ if config.server.host not in LOOPBACK_HOSTS or extra_hosts:
123
+ # The SDK only builds DNS-rebinding settings itself for a bare loopback bind, and it
124
+ # matches Host exactly, so tunnel and LAN hostnames have to be listed here.
125
+ hosts = extra_hosts or [f"{config.server.host}:{config.server.port}"]
126
+ security = TransportSecuritySettings(
127
+ enable_dns_rebinding_protection=True,
128
+ allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*", *hosts],
129
+ allowed_origins=[
130
+ "http://127.0.0.1:*",
131
+ "http://localhost:*",
132
+ "http://[::1]:*",
133
+ *(f"{scheme}://{h}" for h in hosts for scheme in ("http", "https")),
134
+ ],
135
+ )
136
+ app: Starlette = mcp.streamable_http_app(
137
+ streamable_http_path=config.mcp_path, transport_security=security, host=config.server.host
138
+ )
139
+ if config.server.auth == "bearer":
140
+ app.add_middleware(BearerAuth, token=config.server.token, mcp_path=config.mcp_path)
141
+ rt.audit.record(
142
+ "server",
143
+ "start",
144
+ {
145
+ "workspace": str(config.workspace),
146
+ "auth": config.server.auth,
147
+ "sandbox": rt.sandbox.backend.name,
148
+ "tools": toolset.tools,
149
+ },
150
+ )
151
+ return app, rt, toolset
152
+
153
+
154
+ def connection_info(config: Config) -> dict[str, Any]:
155
+ base = config.base_url()
156
+ info: dict[str, Any] = {"mcp_url": base + config.mcp_path, "dashboard_url": base + config.ui_path + "/"}
157
+ if config.server.public_url:
158
+ public = config.server.public_url.rstrip("/")
159
+ info["public_mcp_url"] = public + config.mcp_path
160
+ info["public_dashboard_url"] = public + config.ui_path + "/"
161
+ if config.server.auth == "bearer":
162
+ info["headers"] = {"Authorization": f"Bearer {config.server.token}"}
163
+ return info
164
+
165
+
166
+ def _banner(config: Config, sandbox: str, groups_tools: int, conn_file: Path, *, show_token: bool) -> str:
167
+ """The startup summary. Without `show_token`, every copy of the token is *** and a line
168
+ points to `conn_file`, the connection file, which has it; with auth `none` there is no token."""
169
+ info = connection_info(config)
170
+ add = f"claude mcp add --transport http dev-link {info['mcp_url']}"
171
+ if config.server.auth == "bearer":
172
+ add += f' --header "Authorization: Bearer {config.server.token}"'
173
+ hide = not show_token and bool(config.server.token)
174
+ hidden = [f" token : hidden because this output is not a terminal; see {conn_file}"] if hide else []
175
+ text = "\n".join(
176
+ [
177
+ "",
178
+ " dev-link-mcp ready",
179
+ f" workspace : {config.workspace}",
180
+ f" tools : {groups_tools}",
181
+ f" auth : {config.server.auth}",
182
+ f" sandbox : {sandbox}",
183
+ f" MCP URL : {info['mcp_url']}",
184
+ f" dashboard : {info['dashboard_url']}",
185
+ *(
186
+ [
187
+ f" public MCP URL : {info['public_mcp_url']}",
188
+ f" public dashboard : {info['public_dashboard_url']}",
189
+ ]
190
+ if config.server.public_url
191
+ else []
192
+ ),
193
+ *hidden,
194
+ f" logs : {config.state_dir}",
195
+ *(
196
+ ["", " WARNING: anyone with the public URL can run commands in this workspace."]
197
+ if config.server.public_url
198
+ else []
199
+ ),
200
+ "",
201
+ " Claude Code:",
202
+ f" {add}",
203
+ "",
204
+ ]
205
+ )
206
+ return redact(text, [config.server.token]) if hide else text
207
+
208
+
209
+ class ServeAborted(Exception):
210
+ """The server shut itself down because something it depends on failed; the message says what."""
211
+
212
+
213
+ class _Server(uvicorn.Server):
214
+ """uvicorn.Server that sleeps while idle.
215
+
216
+ uvicorn checks for shutdown every 0.1 s, so an idle server wakes ten times a second. This one
217
+ ticks once a second, which still refreshes the Date header on time, and wakes at once when a
218
+ signal asks it to stop. Each tick also calls `abort_when`; a reason from it stops the server
219
+ and is kept in `aborted`.
220
+ """
221
+
222
+ _loop: asyncio.AbstractEventLoop | None = None
223
+ _wake: asyncio.Event | None = None
224
+ abort_when: Callable[[], str | None] | None = None
225
+ aborted: str | None = None
226
+
227
+ async def main_loop(self) -> None:
228
+ self._loop = asyncio.get_running_loop()
229
+ self._wake = asyncio.Event()
230
+ counter = 0
231
+ while not await self.on_tick(counter):
232
+ if self.abort_when is not None and (reason := self.abort_when()) is not None:
233
+ log.error("shutting down: %s", reason)
234
+ self.aborted = reason
235
+ self.should_exit = True
236
+ break
237
+ # on_tick refreshes the Date header when counter % 10 == 0, so on every tick here.
238
+ counter += 10
239
+ with contextlib.suppress(TimeoutError):
240
+ await asyncio.wait_for(self._wake.wait(), 1)
241
+
242
+ def handle_exit(self, sig: int, frame: FrameType | None) -> None:
243
+ super().handle_exit(sig, frame)
244
+ if self._loop is not None and self._wake is not None:
245
+ # Runs in a signal handler, so hand the wake-up to the loop instead of touching it here.
246
+ self._loop.call_soon_threadsafe(self._wake.set)
247
+
248
+
249
+ async def serve(
250
+ config: Config,
251
+ *,
252
+ on_shutdown: Callable[[], None] | None = None,
253
+ abort_when: Callable[[], str | None] | None = None,
254
+ ) -> None:
255
+ """Run the server until it is told to stop.
256
+
257
+ Writes `state_dir/connection.json` (0600: the URLs plus the workspace and this PID) once the
258
+ tools are registered, and removes it on exit. `on_shutdown` runs first when the server
259
+ stops, before background processes and tool groups are shut down; the CLI stops the tunnel
260
+ there so nothing stays reachable while the rest winds down. `abort_when` is called about
261
+ once a second while the server runs; when it returns a reason, the server shuts down as for
262
+ a signal and then raises ServeAborted with that reason. The CLI uses it to notice a dead
263
+ tunnel. Otherwise raises like build().
264
+ """
265
+ app, rt, toolset = await build(config)
266
+ assert config.state_dir is not None
267
+ conn_file = config.state_dir / "connection.json"
268
+ server: _Server | None = None
269
+ try:
270
+ with os.fdopen(open_private(conn_file), "w") as f:
271
+ json.dump({**connection_info(config), "workspace": str(config.workspace), "pid": os.getpid()}, f, indent=2)
272
+ tool_count = sum(len(names) for names in toolset.tools.values())
273
+ # Output that is not a terminal usually lands in a file (`dev-link start` writes server.log),
274
+ # where the token would outlive the run; on a terminal the banner is how the user gets it.
275
+ banner = _banner(config, rt.sandbox.backend.name, tool_count, conn_file, show_token=sys.stderr.isatty())
276
+ print(banner, file=sys.stderr, flush=True)
277
+ # access_log is off because request lines would contain the path token. SSE streams (an open
278
+ # dashboard tab, an MCP client's GET stream) never end on their own, so without a shutdown
279
+ # timeout uvicorn waits for them forever and Ctrl+C cannot stop the server. log_config=None
280
+ # leaves uvicorn's loggers to the root handlers the CLI sets up, which redact the token.
281
+ server = _Server(
282
+ uvicorn.Config(
283
+ app,
284
+ host=config.server.host,
285
+ port=config.server.port,
286
+ log_level=config.log_level.lower(),
287
+ log_config=None,
288
+ access_log=False,
289
+ timeout_graceful_shutdown=3,
290
+ )
291
+ )
292
+ server.abort_when = abort_when
293
+ async with rt.prune_periodically():
294
+ await server.serve()
295
+ finally:
296
+ try:
297
+ if on_shutdown is not None:
298
+ on_shutdown()
299
+ finally:
300
+ conn_file.unlink(missing_ok=True)
301
+ # Groups such as the browser hold processes that must not outlive the server. Both run at
302
+ # once so their grace periods overlap within the 10 s before `dev-link stop` sends SIGKILL.
303
+ await asyncio.gather(rt.processes.shutdown(), toolset.shutdown())
304
+ aborted = server.aborted if server is not None else None
305
+ rt.audit.record("server", "stop", ok=aborted is None, error=aborted)
306
+ rt.audit.close()
307
+ if aborted is not None:
308
+ raise ServeAborted(aborted)