desklink 0.3.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.
- desklink/__init__.py +3 -0
- desklink/auth/__init__.py +48 -0
- desklink/bridge.py +211 -0
- desklink/cli/__init__.py +4 -0
- desklink/cli/main.py +133 -0
- desklink/config/__init__.py +152 -0
- desklink/models/__init__.py +27 -0
- desklink/oauth/__init__.py +291 -0
- desklink/oauth/server.py +254 -0
- desklink/server/__init__.py +145 -0
- desklink/tunnel/__init__.py +91 -0
- desklink-0.3.0.dist-info/METADATA +267 -0
- desklink-0.3.0.dist-info/RECORD +16 -0
- desklink-0.3.0.dist-info/WHEEL +4 -0
- desklink-0.3.0.dist-info/entry_points.txt +3 -0
- desklink-0.3.0.dist-info/licenses/LICENSE +21 -0
desklink/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Authentication helpers for DeskLink.
|
|
2
|
+
|
|
3
|
+
Currently provides a simple static Bearer token verifier.
|
|
4
|
+
Designed so OAuth / Protected Resource Metadata can be added later.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import secrets
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate_token() -> str:
|
|
15
|
+
"""Generate a cryptographically strong random token."""
|
|
16
|
+
return secrets.token_urlsafe(32)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_or_create_token(token_file: Path) -> str:
|
|
20
|
+
"""Load an existing token or create a new one with restricted permissions."""
|
|
21
|
+
token_file = Path(token_file)
|
|
22
|
+
token_file.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
if token_file.exists():
|
|
24
|
+
return token_file.read_text().strip()
|
|
25
|
+
token = generate_token()
|
|
26
|
+
token_file.write_text(token + "\n")
|
|
27
|
+
try:
|
|
28
|
+
token_file.chmod(0o600)
|
|
29
|
+
except Exception:
|
|
30
|
+
pass # Windows or restricted FS
|
|
31
|
+
return token
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DeskLinkTokenVerifier(TokenVerifier):
|
|
35
|
+
"""Minimal TokenVerifier that accepts a single static Bearer token."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, token: str):
|
|
38
|
+
self.token = token
|
|
39
|
+
|
|
40
|
+
async def verify_token(self, token: str) -> Optional[AccessToken]:
|
|
41
|
+
if token == self.token:
|
|
42
|
+
return AccessToken(
|
|
43
|
+
token=token,
|
|
44
|
+
client_id="desklink-local",
|
|
45
|
+
scopes=["mcp"],
|
|
46
|
+
expires_at=None,
|
|
47
|
+
)
|
|
48
|
+
return None
|
desklink/bridge.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Bridge that proxies tools from local STDIO MCP servers.
|
|
2
|
+
|
|
3
|
+
DeskLink connects to configured STDIO servers (from mcp.json), discovers their
|
|
4
|
+
tools, and exposes them through a single Streamable HTTP endpoint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
from mcp import ClientSession, StdioServerParameters
|
|
14
|
+
from mcp.client.stdio import stdio_client
|
|
15
|
+
from mcp.server.fastmcp import FastMCP
|
|
16
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
17
|
+
|
|
18
|
+
from desklink.config import DeskLinkConfig
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolProxy:
|
|
24
|
+
"""Handles a single tool call with approval and tracing."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, config: DeskLinkConfig, sessions: Dict[str, ClientSession]):
|
|
27
|
+
self.config = config
|
|
28
|
+
self.sessions = sessions
|
|
29
|
+
|
|
30
|
+
def _should_approve(self, tool_name: str) -> bool:
|
|
31
|
+
policy = self.config.settings.approvals.tools.get(
|
|
32
|
+
tool_name, self.config.settings.approvals.default
|
|
33
|
+
)
|
|
34
|
+
if policy == "auto":
|
|
35
|
+
return True
|
|
36
|
+
# Simple console prompt for MVP
|
|
37
|
+
try:
|
|
38
|
+
resp = input(f"\n[DeskLink] Approve tool call '{tool_name}'? [y/N] ").strip().lower()
|
|
39
|
+
return resp in ("y", "yes")
|
|
40
|
+
except EOFError:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
def _trace(
|
|
44
|
+
self,
|
|
45
|
+
tool: str,
|
|
46
|
+
args: dict,
|
|
47
|
+
result: Any,
|
|
48
|
+
duration_ms: float,
|
|
49
|
+
error: Optional[str] = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
if not self.config.settings.tracing.enabled or self.config.settings.tracing.level == "none":
|
|
52
|
+
return
|
|
53
|
+
level = self.config.settings.tracing.level
|
|
54
|
+
entry = {
|
|
55
|
+
"tool": tool,
|
|
56
|
+
"duration_ms": round(duration_ms, 1),
|
|
57
|
+
"error": error,
|
|
58
|
+
}
|
|
59
|
+
if level == "full":
|
|
60
|
+
if self.config.settings.tracing.redact_sensitive:
|
|
61
|
+
sensitive = {"password", "token", "secret", "key", "authorization"}
|
|
62
|
+
safe_args = {
|
|
63
|
+
k: ("***" if k.lower() in sensitive else v) for k, v in args.items()
|
|
64
|
+
}
|
|
65
|
+
else:
|
|
66
|
+
safe_args = args
|
|
67
|
+
entry["args"] = safe_args
|
|
68
|
+
entry["result"] = str(result)[:500] if result else None
|
|
69
|
+
logger.info("TRACE %s", json.dumps(entry, default=str))
|
|
70
|
+
|
|
71
|
+
async def call(self, namespaced_name: str, arguments: dict) -> Any:
|
|
72
|
+
"""Route a namespaced tool call to the correct STDIO session."""
|
|
73
|
+
if "__" not in namespaced_name:
|
|
74
|
+
raise ValueError(f"Invalid tool name: {namespaced_name}")
|
|
75
|
+
server_name, tool_name = namespaced_name.split("__", 1)
|
|
76
|
+
session = self.sessions.get(server_name)
|
|
77
|
+
if not session:
|
|
78
|
+
raise RuntimeError(f"No session for server '{server_name}'")
|
|
79
|
+
|
|
80
|
+
if not self._should_approve(namespaced_name):
|
|
81
|
+
raise PermissionError(f"Tool call '{namespaced_name}' denied by user")
|
|
82
|
+
|
|
83
|
+
start = time.perf_counter()
|
|
84
|
+
error = None
|
|
85
|
+
result = None
|
|
86
|
+
try:
|
|
87
|
+
result = await session.call_tool(tool_name, arguments)
|
|
88
|
+
return result
|
|
89
|
+
except Exception as e:
|
|
90
|
+
error = str(e)
|
|
91
|
+
raise
|
|
92
|
+
finally:
|
|
93
|
+
duration = (time.perf_counter() - start) * 1000
|
|
94
|
+
self._trace(namespaced_name, arguments, result, duration, error)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class McpBridge:
|
|
98
|
+
"""Manages STDIO MCP client sessions and builds a proxy FastMCP server."""
|
|
99
|
+
|
|
100
|
+
def __init__(self, config: DeskLinkConfig):
|
|
101
|
+
self.config = config
|
|
102
|
+
self.sessions: Dict[str, ClientSession] = {}
|
|
103
|
+
self.tools: List[Dict[str, Any]] = []
|
|
104
|
+
self._contexts: List[Any] = []
|
|
105
|
+
|
|
106
|
+
async def start_servers(self) -> None:
|
|
107
|
+
"""Connect to all configured STDIO servers and discover tools."""
|
|
108
|
+
for name, srv in self.config.mcp.mcpServers.items():
|
|
109
|
+
params = StdioServerParameters(
|
|
110
|
+
command=srv.command,
|
|
111
|
+
args=srv.args,
|
|
112
|
+
env=srv.env,
|
|
113
|
+
cwd=srv.cwd,
|
|
114
|
+
)
|
|
115
|
+
try:
|
|
116
|
+
stdio_ctx = stdio_client(params)
|
|
117
|
+
read, write = await stdio_ctx.__aenter__()
|
|
118
|
+
session = ClientSession(read, write)
|
|
119
|
+
await session.__aenter__()
|
|
120
|
+
await session.initialize()
|
|
121
|
+
self.sessions[name] = session
|
|
122
|
+
self._contexts.append((stdio_ctx, session))
|
|
123
|
+
|
|
124
|
+
tools_result = await session.list_tools()
|
|
125
|
+
for t in tools_result.tools:
|
|
126
|
+
self.tools.append(
|
|
127
|
+
{
|
|
128
|
+
"server": name,
|
|
129
|
+
"name": t.name,
|
|
130
|
+
"namespaced": f"{name}__{t.name}",
|
|
131
|
+
"description": t.description or "",
|
|
132
|
+
"inputSchema": t.inputSchema,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
logger.info(
|
|
136
|
+
"Connected to MCP server '%s' (%d tools)", name, len(tools_result.tools)
|
|
137
|
+
)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.error("Failed to start MCP server '%s': %s", name, e)
|
|
140
|
+
|
|
141
|
+
def build_proxy_server(self) -> FastMCP:
|
|
142
|
+
"""Create a FastMCP that proxies all discovered tools.
|
|
143
|
+
|
|
144
|
+
Each proxy tool is registered with a signature that matches the original
|
|
145
|
+
MCP tool's inputSchema (not a synthetic kwargs wrapper). This makes the
|
|
146
|
+
advertised schema and the call path consistent.
|
|
147
|
+
"""
|
|
148
|
+
import inspect
|
|
149
|
+
|
|
150
|
+
mcp = FastMCP(
|
|
151
|
+
"DeskLink Bridge",
|
|
152
|
+
transport_security=TransportSecuritySettings(
|
|
153
|
+
enable_dns_rebinding_protection=False,
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
proxy = ToolProxy(self.config, self.sessions)
|
|
157
|
+
|
|
158
|
+
for tool in self.tools:
|
|
159
|
+
ns_name = tool["namespaced"]
|
|
160
|
+
desc = tool["description"] or f"Proxied tool from {tool['server']}"
|
|
161
|
+
original_schema = tool.get("inputSchema") or {"type": "object", "properties": {}}
|
|
162
|
+
|
|
163
|
+
props = (original_schema or {}).get("properties") or {}
|
|
164
|
+
required = set((original_schema or {}).get("required") or [])
|
|
165
|
+
|
|
166
|
+
# Build a real signature so FastMCP generates the correct parameter schema.
|
|
167
|
+
# Only include properties that exist in the original schema.
|
|
168
|
+
# A synthetic "kwargs" parameter is never added unless the upstream tool defines one.
|
|
169
|
+
params = []
|
|
170
|
+
for pname in props:
|
|
171
|
+
if pname in required:
|
|
172
|
+
params.append(inspect.Parameter(pname, inspect.Parameter.POSITIONAL_OR_KEYWORD))
|
|
173
|
+
else:
|
|
174
|
+
params.append(
|
|
175
|
+
inspect.Parameter(pname, inspect.Parameter.POSITIONAL_OR_KEYWORD, default=None)
|
|
176
|
+
)
|
|
177
|
+
sig = inspect.Signature(params)
|
|
178
|
+
|
|
179
|
+
def make_handler(name: str, signature: inspect.Signature, req: set):
|
|
180
|
+
async def handler(*args: Any, **kwargs: Any) -> Any:
|
|
181
|
+
bound = signature.bind(*args, **kwargs)
|
|
182
|
+
bound.apply_defaults()
|
|
183
|
+
# Drop unset optionals; keep required values even if None
|
|
184
|
+
arguments = {
|
|
185
|
+
k: v for k, v in bound.arguments.items() if v is not None or k in req
|
|
186
|
+
}
|
|
187
|
+
return await proxy.call(name, arguments)
|
|
188
|
+
|
|
189
|
+
handler.__signature__ = signature
|
|
190
|
+
# Safe function name (MCP tool names can contain __)
|
|
191
|
+
handler.__name__ = name.replace("__", "_")
|
|
192
|
+
return handler
|
|
193
|
+
|
|
194
|
+
handler = make_handler(ns_name, sig, required)
|
|
195
|
+
mcp.tool(name=ns_name, description=desc)(handler)
|
|
196
|
+
|
|
197
|
+
# Ensure the advertised schema is exactly the original one
|
|
198
|
+
# (FastMCP may still rewrite some details from the signature).
|
|
199
|
+
registered = mcp._tool_manager._tools.get(ns_name)
|
|
200
|
+
if registered is not None and original_schema:
|
|
201
|
+
registered.parameters = original_schema
|
|
202
|
+
|
|
203
|
+
return mcp
|
|
204
|
+
|
|
205
|
+
async def close(self) -> None:
|
|
206
|
+
for stdio_ctx, session in self._contexts:
|
|
207
|
+
try:
|
|
208
|
+
await session.__aexit__(None, None, None)
|
|
209
|
+
await stdio_ctx.__aexit__(None, None, None)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
desklink/cli/__init__.py
ADDED
desklink/cli/main.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""DeskLink CLI.
|
|
2
|
+
|
|
3
|
+
Primary command: desklink start
|
|
4
|
+
|
|
5
|
+
Supports mcp.json for local STDIO servers and desklink.config.json for settings.
|
|
6
|
+
Also provides tools listing and direct tool invocation for testing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
import typer
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.panel import Panel
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from desklink import __version__
|
|
23
|
+
from desklink.auth import load_or_create_token
|
|
24
|
+
from desklink.bridge import McpBridge
|
|
25
|
+
from desklink.config import ensure_default_filesystem, load_config
|
|
26
|
+
from desklink.oauth import (
|
|
27
|
+
PRESETS,
|
|
28
|
+
OAuthApp,
|
|
29
|
+
OAuthStore,
|
|
30
|
+
generate_client_id,
|
|
31
|
+
generate_client_secret,
|
|
32
|
+
_hash,
|
|
33
|
+
)
|
|
34
|
+
from desklink.server import DeskLinkServer
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(help="Turn your local computer into a remote MCP endpoint.")
|
|
37
|
+
console = Console()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def setup_logging(verbose: bool = False, level: Optional[str] = None) -> None:
|
|
41
|
+
log_level = logging.DEBUG if verbose else getattr(logging, (level or "INFO").upper(), logging.INFO)
|
|
42
|
+
logging.basicConfig(level=log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%H:%M:%S")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_cfg(config, mcp_config, allowed_dirs):
|
|
46
|
+
cfg = load_config(config_path=config, mcp_path=mcp_config)
|
|
47
|
+
if allowed_dirs:
|
|
48
|
+
dirs = [Path(d).resolve() for d in allowed_dirs]
|
|
49
|
+
cfg = ensure_default_filesystem(cfg, dirs)
|
|
50
|
+
else:
|
|
51
|
+
cfg = ensure_default_filesystem(cfg)
|
|
52
|
+
return cfg
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def start(
|
|
57
|
+
mcp_config: Optional[Path] = typer.Option(None, "--mcp-config"),
|
|
58
|
+
config: Optional[Path] = typer.Option(None, "--config"),
|
|
59
|
+
allowed_dirs: Optional[list[str]] = typer.Option(None, "--dir", "-d"),
|
|
60
|
+
port: Optional[int] = typer.Option(None, "--port", "-p"),
|
|
61
|
+
tunnel: Optional[str] = typer.Option(None, "--tunnel"),
|
|
62
|
+
token: Optional[str] = typer.Option(None, "--token"),
|
|
63
|
+
verbose: bool = typer.Option(False, "--verbose", "-v"),
|
|
64
|
+
):
|
|
65
|
+
"""Start the MCP bridge and tunnel."""
|
|
66
|
+
cfg = _load_cfg(config, mcp_config, allowed_dirs)
|
|
67
|
+
if port: cfg.settings.server.port = port
|
|
68
|
+
if tunnel: cfg.settings.tunnel.provider = tunnel
|
|
69
|
+
if token: cfg.settings.auth.token = token
|
|
70
|
+
elif cfg.settings.auth.enabled and not cfg.settings.auth.token:
|
|
71
|
+
cfg.settings.auth.token = load_or_create_token(cfg.settings.auth.token_file)
|
|
72
|
+
setup_logging(verbose, cfg.settings.logging.level)
|
|
73
|
+
console.print(Panel(f"DeskLink v{__version__}", style="bold cyan"))
|
|
74
|
+
server = DeskLinkServer(cfg)
|
|
75
|
+
async def _run():
|
|
76
|
+
local = await server.start_http_server()
|
|
77
|
+
public = server.start_tunnel()
|
|
78
|
+
console.print("[green]MCP bridge started[/green]")
|
|
79
|
+
console.print(f"Local: {local}")
|
|
80
|
+
console.print(f"Remote: {public}")
|
|
81
|
+
if cfg.settings.auth.token:
|
|
82
|
+
console.print(f"Auth token: {cfg.settings.auth.token}")
|
|
83
|
+
await server._shutdown_event.wait()
|
|
84
|
+
try:
|
|
85
|
+
asyncio.run(_run())
|
|
86
|
+
except KeyboardInterrupt:
|
|
87
|
+
pass
|
|
88
|
+
finally:
|
|
89
|
+
asyncio.run(server.shutdown())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command()
|
|
93
|
+
def create(
|
|
94
|
+
kind: str = typer.Argument(..., help="What to create, e.g. oauth-app"),
|
|
95
|
+
preset: str = typer.Argument("custom", help="Agent preset: claude, chatgpt, manus, cursor, custom"),
|
|
96
|
+
name: Optional[str] = typer.Option(None, "--name"),
|
|
97
|
+
public: bool = typer.Option(False, "--public/--confidential", help="Public client (no secret). Default is confidential with a secret."),
|
|
98
|
+
):
|
|
99
|
+
"""Create an OAuth application. Always generates Client ID + Client Secret unless --public."""
|
|
100
|
+
if kind != "oauth-app":
|
|
101
|
+
console.print(f"[red]Unknown create target:[/red] {kind}")
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
preset = preset.lower()
|
|
104
|
+
meta = PRESETS.get(preset, PRESETS["custom"])
|
|
105
|
+
display = name or meta["name"]
|
|
106
|
+
scopes = meta["scopes"]
|
|
107
|
+
client_id = generate_client_id()
|
|
108
|
+
secret = None
|
|
109
|
+
secret_hash = None
|
|
110
|
+
if not public:
|
|
111
|
+
secret = generate_client_secret()
|
|
112
|
+
secret_hash = _hash(secret)
|
|
113
|
+
OAuthStore().save_app(OAuthApp(client_id=client_id, name=display, preset=preset, scopes=scopes, public=public, client_secret_hash=secret_hash, description=meta.get("description", "")))
|
|
114
|
+
console.print(Panel(f"OAuth app created: {display}", style="bold green"))
|
|
115
|
+
console.print(f"[cyan]Client ID:[/cyan] {client_id}")
|
|
116
|
+
if secret:
|
|
117
|
+
console.print(f"[cyan]Client Secret:[/cyan] {secret}")
|
|
118
|
+
else:
|
|
119
|
+
console.print("[dim]Public client (no secret). Use PKCE.[/dim]")
|
|
120
|
+
console.print(f"[cyan]Scopes:[/cyan] {', '.join(scopes)}")
|
|
121
|
+
console.print("\nEndpoints (when DeskLink is running):")
|
|
122
|
+
console.print(" Authorization: http://127.0.0.1:8765/oauth/authorize")
|
|
123
|
+
console.print(" Token: http://127.0.0.1:8765/oauth/token")
|
|
124
|
+
console.print(" MCP: http://127.0.0.1:8765/mcp")
|
|
125
|
+
console.print(" Dashboard: http://127.0.0.1:8765/dashboard")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def main() -> None:
|
|
129
|
+
app()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
if __name__ == "__main__":
|
|
133
|
+
main()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Configuration loading for DeskLink.
|
|
2
|
+
|
|
3
|
+
Supports:
|
|
4
|
+
- desklink.config.json (DeskLink settings: auth, tracing, approvals, server, tunnel)
|
|
5
|
+
- mcp.json (standard MCP server definitions, STDIO by default)
|
|
6
|
+
- Environment variables and CLI overrides
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ServerConfig(BaseModel):
|
|
19
|
+
host: str = "127.0.0.1"
|
|
20
|
+
port: int = 8765
|
|
21
|
+
mcp_path: str = "/mcp"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TunnelConfig(BaseModel):
|
|
25
|
+
provider: str = "ngrok"
|
|
26
|
+
authtoken: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthConfig(BaseModel):
|
|
30
|
+
enabled: bool = True
|
|
31
|
+
token: Optional[str] = None
|
|
32
|
+
token_file: Path = Path.home() / ".desklink" / "token"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TracingConfig(BaseModel):
|
|
36
|
+
enabled: bool = True
|
|
37
|
+
level: str = "basic" # none | basic | full
|
|
38
|
+
redact_sensitive: bool = True
|
|
39
|
+
log_file: Optional[Path] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ApprovalsConfig(BaseModel):
|
|
43
|
+
default: str = "auto" # auto | prompt
|
|
44
|
+
tools: Dict[str, str] = Field(default_factory=dict) # tool_name -> auto|prompt
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class LoggingConfig(BaseModel):
|
|
48
|
+
level: str = "info"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DeskLinkSettings(BaseModel):
|
|
52
|
+
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
|
53
|
+
tracing: TracingConfig = Field(default_factory=TracingConfig)
|
|
54
|
+
auth: AuthConfig = Field(default_factory=AuthConfig)
|
|
55
|
+
approvals: ApprovalsConfig = Field(default_factory=ApprovalsConfig)
|
|
56
|
+
server: ServerConfig = Field(default_factory=ServerConfig)
|
|
57
|
+
tunnel: TunnelConfig = Field(default_factory=TunnelConfig)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class McpServerConfig(BaseModel):
|
|
61
|
+
command: str
|
|
62
|
+
args: List[str] = Field(default_factory=list)
|
|
63
|
+
env: Optional[Dict[str, str]] = None
|
|
64
|
+
cwd: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class McpConfig(BaseModel):
|
|
68
|
+
mcpServers: Dict[str, McpServerConfig] = Field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DeskLinkConfig(BaseSettings):
|
|
72
|
+
model_config = SettingsConfigDict(env_prefix="DESKLINK_", env_nested_delimiter="__")
|
|
73
|
+
|
|
74
|
+
settings: DeskLinkSettings = Field(default_factory=DeskLinkSettings)
|
|
75
|
+
mcp: McpConfig = Field(default_factory=McpConfig)
|
|
76
|
+
|
|
77
|
+
def model_post_init(self, __context: Any) -> None:
|
|
78
|
+
if not self.settings.tunnel.authtoken:
|
|
79
|
+
self.settings.tunnel.authtoken = os.getenv("NGROK_AUTHTOKEN")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load_json(path: Path) -> dict:
|
|
83
|
+
if not path.exists():
|
|
84
|
+
return {}
|
|
85
|
+
try:
|
|
86
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
87
|
+
except Exception:
|
|
88
|
+
return {}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_config(
|
|
92
|
+
config_path: Optional[Path] = None,
|
|
93
|
+
mcp_path: Optional[Path] = None,
|
|
94
|
+
) -> DeskLinkConfig:
|
|
95
|
+
"""Load DeskLink settings and mcp.json.
|
|
96
|
+
|
|
97
|
+
Search order for desklink.config.json:
|
|
98
|
+
- explicit path
|
|
99
|
+
- ./desklink.config.json
|
|
100
|
+
- ~/.desklink/config.json
|
|
101
|
+
|
|
102
|
+
Search order for mcp.json:
|
|
103
|
+
- explicit path
|
|
104
|
+
- ./mcp.json
|
|
105
|
+
- ~/.desklink/mcp.json
|
|
106
|
+
"""
|
|
107
|
+
home = Path.home()
|
|
108
|
+
candidates_config = [
|
|
109
|
+
config_path,
|
|
110
|
+
Path.cwd() / "desklink.config.json",
|
|
111
|
+
home / ".desklink" / "config.json",
|
|
112
|
+
]
|
|
113
|
+
candidates_mcp = [
|
|
114
|
+
mcp_path,
|
|
115
|
+
Path.cwd() / "mcp.json",
|
|
116
|
+
home / ".desklink" / "mcp.json",
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
settings_data = {}
|
|
120
|
+
for p in candidates_config:
|
|
121
|
+
if p and p.exists():
|
|
122
|
+
settings_data = _load_json(p)
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
mcp_data = {}
|
|
126
|
+
for p in candidates_mcp:
|
|
127
|
+
if p and p.exists():
|
|
128
|
+
mcp_data = _load_json(p)
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
settings = DeskLinkSettings.model_validate(settings_data) if settings_data else DeskLinkSettings()
|
|
132
|
+
mcp = McpConfig.model_validate(mcp_data) if mcp_data else McpConfig()
|
|
133
|
+
|
|
134
|
+
cfg = DeskLinkConfig(settings=settings, mcp=mcp)
|
|
135
|
+
return cfg
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def ensure_default_filesystem(cfg: DeskLinkConfig, dirs: Optional[List[Path]] = None) -> DeskLinkConfig:
|
|
139
|
+
"""If no MCP servers are configured, inject the official filesystem server.
|
|
140
|
+
|
|
141
|
+
Uses npx @modelcontextprotocol/server-filesystem with the given directories
|
|
142
|
+
(or home as fallback). This preserves the previous single-filesystem UX.
|
|
143
|
+
"""
|
|
144
|
+
if cfg.mcp.mcpServers:
|
|
145
|
+
return cfg
|
|
146
|
+
|
|
147
|
+
allowed = [str(d) for d in (dirs or [Path.home()])]
|
|
148
|
+
cfg.mcp.mcpServers["filesystem"] = McpServerConfig(
|
|
149
|
+
command="npx",
|
|
150
|
+
args=["-y", "@modelcontextprotocol/server-filesystem"] + allowed,
|
|
151
|
+
)
|
|
152
|
+
return cfg
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Shared data models for DeskLink."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class TunnelInfo:
|
|
10
|
+
"""Information about an established tunnel."""
|
|
11
|
+
|
|
12
|
+
provider: str
|
|
13
|
+
public_url: str
|
|
14
|
+
local_url: str
|
|
15
|
+
tunnel_id: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ServerStatus:
|
|
20
|
+
"""Runtime status of DeskLink."""
|
|
21
|
+
|
|
22
|
+
running: bool = False
|
|
23
|
+
local_url: Optional[str] = None
|
|
24
|
+
public_url: Optional[str] = None
|
|
25
|
+
tools_count: int = 0
|
|
26
|
+
auth_enabled: bool = True
|
|
27
|
+
allowed_directories: List[Path] = field(default_factory=list)
|