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:
parent
352aa8c281
commit
799f507c3c
8 changed files with 290 additions and 46 deletions
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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!())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue