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

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