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.
This commit is contained in:
megaproxy 2026-05-26 11:05:13 +01:00
parent 352aa8c281
commit 799f507c3c
8 changed files with 290 additions and 46 deletions

View file

@ -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 <token-from-panel>"
]
}
}
}
```
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

View file

@ -169,6 +169,7 @@ fn server_status(handle: &McpServerHandle) -> McpStatus {
#[tauri::command]
pub async fn mcp_start(
app: AppHandle,
ptys: tauri::State<'_, Arc<PtyManager>>,
state: tauri::State<'_, Arc<RwLock<McpState>>>,
handle: tauri::State<'_, McpServerHandle>,
@ -181,7 +182,7 @@ pub async fn mcp_start(
}
let ptys_arc: Arc<PtyManager> = (*ptys).clone();
let state_arc: Arc<RwLock<McpState>> = (*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<PtyManager>>,
state: tauri::State<'_, Arc<RwLock<McpState>>>,
handle: tauri::State<'_, McpServerHandle>,
) -> Result<McpStatus, String> {
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<PtyManager> = (*ptys).clone();
let state_arc: Arc<RwLock<McpState>> = (*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>,

View file

@ -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!())

View file

@ -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<PathBuf> {
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<McpPersistedConfig> {
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<Body>,
next: Next,
) -> Result<Response, Response> {
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<Option<RunningServer>>);
pub async fn start_server(
app_handle: AppHandle,
ptys: Arc<PtyManager>,
state: Arc<RwLock<McpState>>,
) -> Result<RunningServer> {
// 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<String> {
let mut cfg = load_or_init_config(app)?;
cfg.token = generate_token();
save_config(app, &cfg)?;
Ok(cfg.token)
}

View file

@ -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)}

View file

@ -9,6 +9,7 @@ interface McpPanelProps {
status: McpStatus;
onStart: () => Promise<void>;
onStop: () => Promise<void>;
onRegenerateToken: () => Promise<void>;
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 (
<>
<button className="backdrop" onClick={onClose} aria-label="Close" />
@ -117,17 +134,30 @@ export default function McpPanel({
{revealToken ? "Hide" : "Show"}
</button>
<button onClick={() => copy(status.token!)}>Copy</button>
<button onClick={regenerate} disabled={regenBusy}>
{regenBusy ? "…" : "Regenerate"}
</button>
</div>
<p className="mcp-hint">
URL + token persist across restarts paste the snippet
into your Claude config once. Regenerate if the token
leaks.
</p>
</div>
<div className="mcp-field">
<label>Claude config snippet</label>
<label>Claude Code config snippet (.mcp.json)</label>
<pre className="mcp-snippet">
{`{
"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({
</div>
<div className="mcp-tips">
<strong>WSL connectivity:</strong> for Claude running inside
WSL to reach this server, enable mirrored networking in your
<code> %UserProfile%\.wslconfig</code> (Win11 22H2+):
<pre>{`[wsl2]
networkingMode=mirrored`}</pre>
Then <code>127.0.0.1</code> in WSL routes to this Windows
host. Without mirrored mode you'll need to use the WSL
gateway IP.
<strong>Why the shim?</strong> Claude Code's HTTP-MCP
client tries OAuth discovery and ignores static{" "}
<code>headers</code> auth (Anthropic issues #17152, #46879).
The <code>mcp-remote</code> 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{" "}
<code>Authorization</code> header.
<br />
<br />
<strong>WSL connectivity:</strong> the URL uses{" "}
<code>127.0.0.1</code>; a Claude session running inside
WSL needs to either swap that for the WSL gateway IP
(<code>ip route show default | awk '{`{print $3}`}'</code>{" "}
inside WSL changes after each WSL restart), or enable
mirrored networking (<code>networkingMode=mirrored</code>{" "}
in <code>%UserProfile%\.wslconfig</code>, Win11 22H2+)
so <code>127.0.0.1</code> in WSL routes to this host.
You'll likely also need to allow the port through Windows
Defender Firewall:{" "}
<code>
New-NetFirewallRule -DisplayName 'tiletopia MCP'
-Direction Inbound -Action Allow -Protocol TCP
-LocalPort {status.url.match(/:(\d+)\//)?.[1] ?? "47821"}{" "}
-Profile Any
</code>{" "}
(elevated PowerShell).
</div>
</>
)}
@ -178,11 +233,12 @@ networkingMode=mirrored`}</pre>
)}
<p className="mcp-security">
<strong>Security:</strong> 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 <em>never</em> exposed through MCP.
<strong>Security:</strong> bound to <code>0.0.0.0</code> 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{" "}
<em>never</em> exposed through MCP.
</p>
</div>
</div>

View file

@ -130,5 +130,7 @@ export interface McpMirroredHost {
export const mcpStart = (): Promise<McpStatus> => invoke("mcp_start");
export const mcpStop = (): Promise<McpStatus> => invoke("mcp_stop");
export const mcpStatus = (): Promise<McpStatus> => invoke("mcp_status");
export const mcpRegenerateToken = (): Promise<McpStatus> =>
invoke("mcp_regenerate_token");
export const mcpUpdateState = (mirror: McpMirror): Promise<void> =>
invoke("mcp_update_state", { mirror });

View file

@ -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).",
},
];