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 ADDED
@@ -0,0 +1,3 @@
1
+ """DeskLink: Turn your local computer into a remotely accessible MCP endpoint."""
2
+
3
+ __version__ = "0.3.0"
@@ -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
@@ -0,0 +1,4 @@
1
+ """DeskLink CLI package."""
2
+ from .main import app
3
+
4
+ __all__ = ["app"]
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)