From 799f507c3cae7d0d4858bbdfe9248f05c8b4f1de Mon Sep 17 00:00:00 2001 From: megaproxy Date: Tue, 26 May 2026 11:05:13 +0100 Subject: [PATCH] MCP: persistent port/token + mcp-remote shim recipe for Claude Code Port (default 47821) and bearer token now persist to mcp.json with OS-picked fallback if the port is taken; new Regenerate button in the panel rotates the token and restarts the running server. rmcp's DNS-rebinding host allowlist is disabled so WSL gateway IPs can connect (bearer-auth handles the gatekeeping); the auth middleware only enforces on /mcp paths so OAuth-discovery clients don't see a Bearer challenge on /.well-known/* probes. Claude Code's HTTP-MCP client currently tries OAuth and ignores static `headers` auth (anthropics/claude-code#17152, #46879), so the panel + README config snippet now uses `npx mcp-remote` as a stdio shim that proxies the HTTP endpoint with the bearer baked in. --- README.md | 42 ++++++++-- src-tauri/src/commands.rs | 27 ++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/mcp.rs | 150 ++++++++++++++++++++++++++++++++---- src/App.tsx | 16 ++++ src/components/McpPanel.tsx | 96 ++++++++++++++++++----- src/ipc.ts | 2 + src/lib/shortcuts.ts | 2 +- 8 files changed, 290 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 826acc2..ad5214c 100644 --- a/README.md +++ b/README.md @@ -60,19 +60,45 @@ Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\works ### MCP server (Claude can drive the workspace) -The titlebar 🤖 button opens a small panel that starts an MCP (Model Context Protocol) server on `127.0.0.1`. A Claude session — running anywhere on the machine, including inside one of tiletopia's own panes — can connect to it, read scrollback, wait for commands to settle, and inspect the layout. v1 is **read-only**: no spawning, no keystroke injection, no host editing. +The titlebar 🤖 button opens a small panel that starts an MCP (Model Context Protocol) server. A Claude session — running anywhere reachable from the host, including inside one of tiletopia's own panes — can connect to it, read scrollback, wait for commands to settle, and inspect the layout. v1 is **read-only**: no spawning, no keystroke injection, no host editing. -- **Off by default.** Click the button, hit **Server: ON** to start. The panel shows the bound URL + a randomly-generated bearer token and a ready-to-paste Claude config snippet. +- **Off by default.** Click the button, hit **Server: ON** to start. The panel shows the URL + bearer token and a ready-to-paste Claude config snippet. Both port and token persist across restarts (saved to `%APPDATA%\com.megaproxy.tiletopia\mcp.json`); use **Regenerate** in the panel if the token leaks. - **Default-deny per pane.** Toggle the 🤖 chip in any pane's toolbar to allow MCP to see it. Panes without the chip on are invisible to the server. - **Saved SSH passwords are never exposed** through the MCP surface. -- **WSL connectivity.** For Claude running inside WSL2 to reach the Windows-side server at `127.0.0.1`, set `networkingMode=mirrored` in `%UserProfile%\.wslconfig` (Win 11 22H2+): +- **Bound to all interfaces** (`0.0.0.0`). The bearer token is the only auth — don't enable the server on an untrusted network. - ``` - [wsl2] - networkingMode=mirrored - ``` +#### Claude Code setup (via `mcp-remote` stdio shim) - Without mirrored mode you can still connect via the WSL gateway IP (default route). +Claude Code's HTTP-MCP client currently tries OAuth discovery and ignores static `headers` auth (Anthropic [#17152](https://github.com/anthropics/claude-code/issues/17152), [#46879](https://github.com/anthropics/claude-code/issues/46879)). The [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) stdio shim transparently proxies the HTTP endpoint with the bearer header attached, sidestepping the OAuth flow. + +The panel's config snippet uses this shim by default — paste it into your project's `.mcp.json`: + +```json +{ + "mcpServers": { + "tiletopia": { + "command": "npx", + "args": [ + "-y", "mcp-remote", + "http://127.0.0.1:47821/mcp", + "--allow-http", + "--header", "Authorization: Bearer " + ] + } + } +} +``` + +Requires `npx` (Node 18+) on the client side. Other MCP clients that handle static bearer auth correctly can skip the shim and connect directly to the URL + token shown in the panel. + +#### WSL connectivity + +When Claude runs inside WSL, swap `127.0.0.1` for the WSL gateway IP (`ip route show default | awk '{print $3}'` inside WSL — note that this changes after each WSL restart) **or** enable mirrored networking (`networkingMode=mirrored` in `%UserProfile%\.wslconfig` then `wsl --shutdown`; Win 11 22H2+). Allow the port through Windows Defender Firewall once — elevated PowerShell: + +```powershell +New-NetFirewallRule -DisplayName "tiletopia MCP" -Direction Inbound ` + -Action Allow -Protocol TCP -LocalPort 47821 -Profile Any +``` ## Stack diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index efa6ac9..ea2db88 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -169,6 +169,7 @@ fn server_status(handle: &McpServerHandle) -> McpStatus { #[tauri::command] pub async fn mcp_start( + app: AppHandle, ptys: tauri::State<'_, Arc>, state: tauri::State<'_, Arc>>, handle: tauri::State<'_, McpServerHandle>, @@ -181,7 +182,7 @@ pub async fn mcp_start( } let ptys_arc: Arc = (*ptys).clone(); let state_arc: Arc> = (*state).clone(); - let running: RunningServer = mcp::start_server(ptys_arc, state_arc) + let running: RunningServer = mcp::start_server(app, ptys_arc, state_arc) .await .map_err(|e| e.to_string())?; { @@ -199,6 +200,30 @@ pub async fn mcp_stop( Ok(server_status(&handle)) } +/// Mint a fresh bearer token and persist it. If the server is currently +/// running, restart it so the new token takes effect (the existing auth +/// middleware captured the old token by value). +#[tauri::command] +pub async fn mcp_regenerate_token( + app: AppHandle, + ptys: tauri::State<'_, Arc>, + state: tauri::State<'_, Arc>>, + handle: tauri::State<'_, McpServerHandle>, +) -> Result { + let was_running = handle.0.lock().is_some(); + mcp::regenerate_token(&app).map_err(|e| e.to_string())?; + if was_running { + mcp::stop_server(&handle); + let ptys_arc: Arc = (*ptys).clone(); + let state_arc: Arc> = (*state).clone(); + let running: RunningServer = mcp::start_server(app, ptys_arc, state_arc) + .await + .map_err(|e| e.to_string())?; + *handle.0.lock() = Some(running); + } + Ok(server_status(&handle)) +} + #[tauri::command] pub async fn mcp_status( handle: tauri::State<'_, McpServerHandle>, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ca7b92..520411f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -59,6 +59,7 @@ pub fn run() { commands::mcp_start, commands::mcp_stop, commands::mcp_status, + commands::mcp_regenerate_token, commands::mcp_update_state, ]) .run(tauri::generate_context!()) diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 2b8bc38..461f56b 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -14,10 +14,11 @@ use std::collections::HashMap; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; -use anyhow::Result; +use anyhow::{Context, Result}; use axum::{ body::Body, http::{HeaderMap, HeaderValue, Request, StatusCode}, @@ -32,17 +33,85 @@ use rmcp::{ schemars, tool, tool_handler, tool_router, service::RequestContext, transport::streamable_http_server::{ - session::local::LocalSessionManager, tower::StreamableHttpService, + session::local::LocalSessionManager, + tower::{StreamableHttpServerConfig, StreamableHttpService}, }, ErrorData as McpError, RoleServer, ServerHandler, }; use serde::{Deserialize, Serialize}; use serde_json::json; +use tauri::{AppHandle, Manager}; use tokio::{net::TcpListener, sync::RwLock, task::JoinHandle}; use tokio_util::sync::CancellationToken; use crate::pty::{PaneId, PtyManager}; +/// Default port for the MCP server. Picked from the IANA-unassigned +/// 47000-range so it's unlikely to collide with anything else on a dev box. +/// Override by editing `port` in `%APPDATA%\com.megaproxy.tiletopia\mcp.json`. +pub const DEFAULT_PORT: u16 = 47821; + +const MCP_CONFIG_FILE: &str = "mcp.json"; + +/// Persisted across restarts so the firewall rule + Claude config snippet +/// don't need re-pasting every launch. Generated on first start. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpPersistedConfig { + pub port: u16, + pub token: String, +} + +impl McpPersistedConfig { + fn new_default() -> Self { + Self { + port: DEFAULT_PORT, + token: generate_token(), + } + } +} + +fn generate_token() -> String { + use rand::RngCore; + let mut buf = [0u8; 32]; + rand::rng().fill_bytes(&mut buf); + hex::encode(buf) +} + +fn config_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_config_dir() + .map_err(|e| anyhow::anyhow!("app_config_dir: {e}"))?; + Ok(dir.join(MCP_CONFIG_FILE)) +} + +/// Load saved config, or generate-and-save a fresh one on first run. +pub fn load_or_init_config(app: &AppHandle) -> Result { + let path = config_path(app)?; + if path.exists() { + let raw = std::fs::read_to_string(&path).context("read mcp.json")?; + let cfg: McpPersistedConfig = + serde_json::from_str(&raw).context("parse mcp.json")?; + return Ok(cfg); + } + let cfg = McpPersistedConfig::new_default(); + save_config(app, &cfg)?; + Ok(cfg) +} + +pub fn save_config(app: &AppHandle, cfg: &McpPersistedConfig) -> Result<()> { + let path = config_path(app)?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).context("create_dir_all")?; + } + let tmp = path.with_extension("json.tmp"); + let json = serde_json::to_string_pretty(cfg).context("serialize mcp cfg")?; + std::fs::write(&tmp, json.as_bytes()).context("write tmp mcp.json")?; + // Atomic on Unix; MoveFileEx with REPLACE_EXISTING on Windows. + std::fs::rename(&tmp, &path).context("rename mcp.json")?; + Ok(()) +} + // ---------------------------------------------------------------------------- // Shared state mirrored from the frontend. // ---------------------------------------------------------------------------- @@ -357,13 +426,31 @@ async fn bearer_auth( req: Request, next: Next, ) -> Result { - let supplied = headers + // OAuth-discovery clients probe /.well-known/* and /register before any + // /mcp request. Letting those fall through to axum's default 404 keeps + // us out of the OAuth challenge/response game — bearer enforcement only + // applies to the real MCP surface. + if !req.uri().path().starts_with("/mcp") { + return Ok(next.run(req).await); + } + + let auth_header = headers .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ")); + .and_then(|v| v.to_str().ok()); + let supplied = auth_header.and_then(|s| s.strip_prefix("Bearer ")); let ok = supplied .map(|t| constant_time_eq(t.as_bytes(), expected.as_bytes())) .unwrap_or(false); + + tracing::debug!( + method = %req.method(), + path = %req.uri().path(), + auth_present = auth_header.is_some(), + bearer_present = supplied.is_some(), + token_match = ok, + "MCP request" + ); + if ok { return Ok(next.run(req).await); } @@ -405,14 +492,12 @@ pub struct RunningServer { pub struct McpServerHandle(pub PlMutex>); pub async fn start_server( + app_handle: AppHandle, ptys: Arc, state: Arc>, ) -> Result { - // 256-bit bearer token, hex-encoded. - use rand::RngCore; - let mut buf = [0u8; 32]; - rand::rng().fill_bytes(&mut buf); - let token = hex::encode(buf); + let cfg = load_or_init_config(&app_handle)?; + let token = cfg.token.clone(); state.write().await.bearer_token = token.clone(); let cancel = CancellationToken::new(); @@ -420,26 +505,49 @@ pub async fn start_server( // Fresh service per session; cheap because we share state via Arcs. let ptys_f = ptys.clone(); let state_f = state.clone(); + // Disable rmcp's DNS-rebinding host allowlist. The default only permits + // localhost / 127.0.0.1 / ::1; legitimate WSL clients connect via the + // dynamic WSL gateway IP (172.x.x.1) which can't be in any static list. + // Bearer-token auth on /mcp is the real gatekeeper, and we're not + // running in a browser context where DNS rebinding is a concern. let mcp_service = StreamableHttpService::new( move || Ok(TileService::new(ptys_f.clone(), state_f.clone())), LocalSessionManager::default().into(), - Default::default(), + StreamableHttpServerConfig::default().disable_allowed_hosts(), ); - let app = Router::new() + let router = Router::new() .nest_service("/mcp", mcp_service) .layer(middleware::from_fn_with_state( Arc::new(token.clone()), bearer_auth, )); - // Port 0 → OS picks. Recover via local_addr() before serving. - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; + // Bind to all interfaces so WSL distros in NAT mode can reach the server + // via the Windows host's WSL-side adapter IP. Auth is bearer-token only. + // We report 127.0.0.1 in the URL since that's the canonical Windows-side + // hostname (WSL clients swap in the gateway IP). + // + // Try the saved port first so the user's firewall rule + Claude config + // survive restarts. If it's taken, fall back to an OS-picked port and + // leave the saved port alone — the conflict may clear later. + let listener = match TcpListener::bind(("0.0.0.0", cfg.port)).await { + Ok(l) => l, + Err(e) => { + tracing::warn!( + "MCP saved port {} unavailable ({}); falling back to OS-picked port", + cfg.port, + e + ); + TcpListener::bind("0.0.0.0:0").await? + } + }; + let port = listener.local_addr()?.port(); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); let cancel_inner = cancel.clone(); let task = tokio::spawn(async move { - let _ = axum::serve(listener, app) + let _ = axum::serve(listener, router) .with_graceful_shutdown(async move { cancel_inner.cancelled().await; }) @@ -462,3 +570,13 @@ pub fn stop_server(handle: &McpServerHandle) { tracing::info!("MCP server stopped"); } } + +/// Mint a new bearer token, persist it, and return the new value. Caller is +/// responsible for restarting the server if it was running — the live auth +/// middleware captures the token by value at start time. +pub fn regenerate_token(app: &AppHandle) -> Result { + let mut cfg = load_or_init_config(app)?; + cfg.token = generate_token(); + save_config(app, &cfg)?; + Ok(cfg.token) +} diff --git a/src/App.tsx b/src/App.tsx index 4c4992d..4662d59 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { mcpStart, mcpStop, mcpStatus as mcpStatusCmd, + mcpRegenerateToken, mcpUpdateState, writeToPane, killPane, @@ -343,6 +344,20 @@ export default function App() { } }, [notify]); + const regenerateMcpToken = useCallback(async () => { + try { + const st = await mcpRegenerateToken(); + setMcpStatus(st); + notify( + st.running + ? "MCP token regenerated — update your client config" + : "MCP token regenerated", + ); + } catch (e) { + notify(`MCP token regen failed: ${e}`); + } + }, [notify]); + // On mount, sync our local mcpStatus with whatever's already running // (the backend persists state across HMR reloads). useEffect(() => { @@ -1051,6 +1066,7 @@ export default function App() { status={mcpStatus} onStart={startMcp} onStop={stopMcp} + onRegenerateToken={regenerateMcpToken} onClose={() => setMcpPanelOpen(false)} allowedPaneCount={allowedPaneCount} totalPaneCount={leafCount(tree)} diff --git a/src/components/McpPanel.tsx b/src/components/McpPanel.tsx index c2c01b6..abe7cc2 100644 --- a/src/components/McpPanel.tsx +++ b/src/components/McpPanel.tsx @@ -9,6 +9,7 @@ interface McpPanelProps { status: McpStatus; onStart: () => Promise; onStop: () => Promise; + onRegenerateToken: () => Promise; onClose: () => void; /** Count of leaves with mcpAllow=true — shown so the user knows whether * enabling the server will actually expose anything. */ @@ -21,12 +22,14 @@ export default function McpPanel({ status, onStart, onStop, + onRegenerateToken, onClose, allowedPaneCount, totalPaneCount, }: McpPanelProps) { const [busy, setBusy] = useState(false); const [revealToken, setRevealToken] = useState(false); + const [regenBusy, setRegenBusy] = useState(false); useEffect(() => { function onKey(e: KeyboardEvent) { @@ -56,6 +59,20 @@ export default function McpPanel({ ); }, []); + const regenerate = useCallback(async () => { + if (regenBusy) return; + const warn = status.running + ? "Regenerate token? Existing MCP clients will be disconnected and need the new token to reconnect." + : "Regenerate token? Any saved client config with the old token will stop working."; + if (!window.confirm(warn)) return; + setRegenBusy(true); + try { + await onRegenerateToken(); + } finally { + setRegenBusy(false); + } + }, [regenBusy, status.running, onRegenerateToken]); + return ( <> + +

+ URL + token persist across restarts — paste the snippet + into your Claude config once. Regenerate if the token + leaks. +

- +
 {`{
   "mcpServers": {
     "tiletopia": {
-      "url": "${status.url}",
-      "headers": { "Authorization": "Bearer ${status.token}" }
+      "command": "npx",
+      "args": [
+        "-y", "mcp-remote",
+        "${status.url}",
+        "--allow-http",
+        "--header", "Authorization: Bearer ${status.token}"
+      ]
     }
   }
 }`}
@@ -139,10 +169,15 @@ export default function McpPanel({
                         {
                           mcpServers: {
                             tiletopia: {
-                              url: status.url,
-                              headers: {
-                                Authorization: `Bearer ${status.token}`,
-                              },
+                              command: "npx",
+                              args: [
+                                "-y",
+                                "mcp-remote",
+                                status.url,
+                                "--allow-http",
+                                "--header",
+                                `Authorization: Bearer ${status.token}`,
+                              ],
                             },
                           },
                         },
@@ -157,14 +192,34 @@ export default function McpPanel({
               
- WSL connectivity: for Claude running inside - WSL to reach this server, enable mirrored networking in your - %UserProfile%\.wslconfig (Win11 22H2+): -
{`[wsl2]
-networkingMode=mirrored`}
- Then 127.0.0.1 in WSL routes to this Windows - host. Without mirrored mode you'll need to use the WSL - gateway IP. + Why the shim? Claude Code's HTTP-MCP + client tries OAuth discovery and ignores static{" "} + headers auth (Anthropic issues #17152, #46879). + The mcp-remote stdio shim transparently + proxies the HTTP endpoint with the bearer header attached, + which sidesteps the OAuth flow entirely. Other MCP + clients that handle bearer auth correctly can connect + directly to the URL above with the token in an{" "} + Authorization header. +
+
+ WSL connectivity: the URL uses{" "} + 127.0.0.1; a Claude session running inside + WSL needs to either swap that for the WSL gateway IP + (ip route show default | awk '{`{print $3}`}'{" "} + inside WSL — changes after each WSL restart), or enable + mirrored networking (networkingMode=mirrored{" "} + in %UserProfile%\.wslconfig, Win11 22H2+) + so 127.0.0.1 in WSL routes to this host. + You'll likely also need to allow the port through Windows + Defender Firewall:{" "} + + New-NetFirewallRule -DisplayName 'tiletopia MCP' + -Direction Inbound -Action Allow -Protocol TCP + -LocalPort {status.url.match(/:(\d+)\//)?.[1] ?? "47821"}{" "} + -Profile Any + {" "} + (elevated PowerShell).
)} @@ -178,11 +233,12 @@ networkingMode=mirrored`} )}

- Security: bound to 127.0.0.1 only. Anyone on - this machine running as you can read the bearer token if they - see it (e.g. via this UI or by guessing the localhost port). - Treat MCP access as equivalent to terminal access. Saved SSH - passwords are never exposed through MCP. + Security: bound to 0.0.0.0 so WSL + distros and other machines on your LAN can reach it; bearer + token is the only thing keeping them out. Treat MCP access as + equivalent to terminal access — don't share the token, don't + run the server on an untrusted network. Saved SSH passwords are{" "} + never exposed through MCP.

diff --git a/src/ipc.ts b/src/ipc.ts index 0f0024f..fb00ffd 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -130,5 +130,7 @@ export interface McpMirroredHost { export const mcpStart = (): Promise => invoke("mcp_start"); export const mcpStop = (): Promise => invoke("mcp_stop"); export const mcpStatus = (): Promise => invoke("mcp_status"); +export const mcpRegenerateToken = (): Promise => + invoke("mcp_regenerate_token"); export const mcpUpdateState = (mirror: McpMirror): Promise => invoke("mcp_update_state", { mirror }); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index c06521d..f9db405 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -110,6 +110,6 @@ export const TIPS: TipSpec[] = [ }, { title: "MCP server (let Claude drive the workspace)", - body: "Titlebar 🤖 opens the MCP control panel — start the server, copy the URL + bearer token into your Claude client config, and Claude can read scrollback / wait for commands to settle. Default-deny per pane: toggle 🤖 on each pane's toolbar to make it visible to MCP. Read-only in v1 (no spawn or write yet). For Claude inside WSL, enable mirrored networking in .wslconfig.", + body: "Titlebar 🤖 opens the MCP control panel — start the server and paste the snippet into your Claude Code .mcp.json. The snippet uses npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead; the shim proxies the HTTP endpoint with the bearer baked in. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP. Read-only in v1 (no spawn or write yet).", }, ];