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.
- dev_link_mcp/__init__.py +5 -0
- dev_link_mcp/__main__.py +3 -0
- dev_link_mcp/app.py +308 -0
- dev_link_mcp/audit.py +625 -0
- dev_link_mcp/browse.py +260 -0
- dev_link_mcp/cli/__init__.py +5 -0
- dev_link_mcp/cli/common.py +75 -0
- dev_link_mcp/cli/config_cmd.py +88 -0
- dev_link_mcp/cli/doctor.py +184 -0
- dev_link_mcp/cli/info.py +102 -0
- dev_link_mcp/cli/instance.py +232 -0
- dev_link_mcp/cli/main.py +177 -0
- dev_link_mcp/cli/serve.py +303 -0
- dev_link_mcp/cli/tools_cmd.py +92 -0
- dev_link_mcp/cli/tunnel.py +134 -0
- dev_link_mcp/config/__init__.py +31 -0
- dev_link_mcp/config/loader.py +248 -0
- dev_link_mcp/config/models.py +291 -0
- dev_link_mcp/config/schema.py +41 -0
- dev_link_mcp/dashboard/__init__.py +0 -0
- dev_link_mcp/dashboard/css/app.css +326 -0
- dev_link_mcp/dashboard/index.html +181 -0
- dev_link_mcp/dashboard/js/activity.js +389 -0
- dev_link_mcp/dashboard/js/api.js +39 -0
- dev_link_mcp/dashboard/js/dom.js +36 -0
- dev_link_mcp/dashboard/js/drawer.js +195 -0
- dev_link_mcp/dashboard/js/files.js +273 -0
- dev_link_mcp/dashboard/js/fmt.js +33 -0
- dev_link_mcp/dashboard/js/live.js +56 -0
- dev_link_mcp/dashboard/js/main.js +100 -0
- dev_link_mcp/dashboard/js/processes.js +214 -0
- dev_link_mcp/dashboard/js/stats.js +200 -0
- dev_link_mcp/dashboard/js/tasks.js +63 -0
- dev_link_mcp/dashboard/js/tools.js +152 -0
- dev_link_mcp/dashboard/js/ui.js +70 -0
- dev_link_mcp/dashboard/js/vlist.js +110 -0
- dev_link_mcp/live.py +41 -0
- dev_link_mcp/middleware.py +110 -0
- dev_link_mcp/orphans.py +96 -0
- dev_link_mcp/patterns.py +232 -0
- dev_link_mcp/private.py +21 -0
- dev_link_mcp/processes.py +467 -0
- dev_link_mcp/py.typed +0 -0
- dev_link_mcp/runtime.py +121 -0
- dev_link_mcp/sandbox/__init__.py +454 -0
- dev_link_mcp/sandbox/backends/__init__.py +63 -0
- dev_link_mcp/sandbox/backends/bubblewrap.py +235 -0
- dev_link_mcp/sandbox/backends/none.py +21 -0
- dev_link_mcp/sandbox/limits.py +162 -0
- dev_link_mcp/tools/__init__.py +394 -0
- dev_link_mcp/tools/base.py +52 -0
- dev_link_mcp/tools/browser.py +1099 -0
- dev_link_mcp/tools/checkpoints.py +387 -0
- dev_link_mcp/tools/code.py +488 -0
- dev_link_mcp/tools/common.py +22 -0
- dev_link_mcp/tools/data.py +444 -0
- dev_link_mcp/tools/docker.py +1072 -0
- dev_link_mcp/tools/files.py +813 -0
- dev_link_mcp/tools/http.py +500 -0
- dev_link_mcp/tools/processes.py +269 -0
- dev_link_mcp/tools/project/__init__.py +227 -0
- dev_link_mcp/tools/project/detect.py +775 -0
- dev_link_mcp/tools/project/parsers.py +400 -0
- dev_link_mcp/tools/search.py +443 -0
- dev_link_mcp/tools/shell.py +134 -0
- dev_link_mcp/tools/system.py +123 -0
- dev_link_mcp/tools/tasks.py +347 -0
- dev_link_mcp/web.py +375 -0
- dev_link_mcp/workspace.py +320 -0
- dev_link_mcp-0.1.0.dist-info/METADATA +291 -0
- dev_link_mcp-0.1.0.dist-info/RECORD +74 -0
- dev_link_mcp-0.1.0.dist-info/WHEEL +4 -0
- dev_link_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- dev_link_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
dev_link_mcp/__init__.py
ADDED
dev_link_mcp/__main__.py
ADDED
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)
|