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
40
README.md
40
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)
|
### 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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
```
|
#### Claude Code setup (via `mcp-remote` stdio shim)
|
||||||
[wsl2]
|
|
||||||
networkingMode=mirrored
|
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>"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Without mirrored mode you can still connect via the WSL gateway IP (default route).
|
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
|
## Stack
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,7 @@ fn server_status(handle: &McpServerHandle) -> McpStatus {
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn mcp_start(
|
pub async fn mcp_start(
|
||||||
|
app: AppHandle,
|
||||||
ptys: tauri::State<'_, Arc<PtyManager>>,
|
ptys: tauri::State<'_, Arc<PtyManager>>,
|
||||||
state: tauri::State<'_, Arc<RwLock<McpState>>>,
|
state: tauri::State<'_, Arc<RwLock<McpState>>>,
|
||||||
handle: tauri::State<'_, McpServerHandle>,
|
handle: tauri::State<'_, McpServerHandle>,
|
||||||
|
|
@ -181,7 +182,7 @@ pub async fn mcp_start(
|
||||||
}
|
}
|
||||||
let ptys_arc: Arc<PtyManager> = (*ptys).clone();
|
let ptys_arc: Arc<PtyManager> = (*ptys).clone();
|
||||||
let state_arc: Arc<RwLock<McpState>> = (*state).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
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
{
|
{
|
||||||
|
|
@ -199,6 +200,30 @@ pub async fn mcp_stop(
|
||||||
Ok(server_status(&handle))
|
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]
|
#[tauri::command]
|
||||||
pub async fn mcp_status(
|
pub async fn mcp_status(
|
||||||
handle: tauri::State<'_, McpServerHandle>,
|
handle: tauri::State<'_, McpServerHandle>,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ pub fn run() {
|
||||||
commands::mcp_start,
|
commands::mcp_start,
|
||||||
commands::mcp_stop,
|
commands::mcp_stop,
|
||||||
commands::mcp_status,
|
commands::mcp_status,
|
||||||
|
commands::mcp_regenerate_token,
|
||||||
commands::mcp_update_state,
|
commands::mcp_update_state,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,11 @@
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{HeaderMap, HeaderValue, Request, StatusCode},
|
http::{HeaderMap, HeaderValue, Request, StatusCode},
|
||||||
|
|
@ -32,17 +33,85 @@ use rmcp::{
|
||||||
schemars, tool, tool_handler, tool_router,
|
schemars, tool, tool_handler, tool_router,
|
||||||
service::RequestContext,
|
service::RequestContext,
|
||||||
transport::streamable_http_server::{
|
transport::streamable_http_server::{
|
||||||
session::local::LocalSessionManager, tower::StreamableHttpService,
|
session::local::LocalSessionManager,
|
||||||
|
tower::{StreamableHttpServerConfig, StreamableHttpService},
|
||||||
},
|
},
|
||||||
ErrorData as McpError, RoleServer, ServerHandler,
|
ErrorData as McpError, RoleServer, ServerHandler,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
use tokio::{net::TcpListener, sync::RwLock, task::JoinHandle};
|
use tokio::{net::TcpListener, sync::RwLock, task::JoinHandle};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::pty::{PaneId, PtyManager};
|
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.
|
// Shared state mirrored from the frontend.
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
@ -357,13 +426,31 @@ async fn bearer_auth(
|
||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, Response> {
|
) -> 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)
|
.get(axum::http::header::AUTHORIZATION)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok());
|
||||||
.and_then(|s| s.strip_prefix("Bearer "));
|
let supplied = auth_header.and_then(|s| s.strip_prefix("Bearer "));
|
||||||
let ok = supplied
|
let ok = supplied
|
||||||
.map(|t| constant_time_eq(t.as_bytes(), expected.as_bytes()))
|
.map(|t| constant_time_eq(t.as_bytes(), expected.as_bytes()))
|
||||||
.unwrap_or(false);
|
.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 {
|
if ok {
|
||||||
return Ok(next.run(req).await);
|
return Ok(next.run(req).await);
|
||||||
}
|
}
|
||||||
|
|
@ -405,14 +492,12 @@ pub struct RunningServer {
|
||||||
pub struct McpServerHandle(pub PlMutex<Option<RunningServer>>);
|
pub struct McpServerHandle(pub PlMutex<Option<RunningServer>>);
|
||||||
|
|
||||||
pub async fn start_server(
|
pub async fn start_server(
|
||||||
|
app_handle: AppHandle,
|
||||||
ptys: Arc<PtyManager>,
|
ptys: Arc<PtyManager>,
|
||||||
state: Arc<RwLock<McpState>>,
|
state: Arc<RwLock<McpState>>,
|
||||||
) -> Result<RunningServer> {
|
) -> Result<RunningServer> {
|
||||||
// 256-bit bearer token, hex-encoded.
|
let cfg = load_or_init_config(&app_handle)?;
|
||||||
use rand::RngCore;
|
let token = cfg.token.clone();
|
||||||
let mut buf = [0u8; 32];
|
|
||||||
rand::rng().fill_bytes(&mut buf);
|
|
||||||
let token = hex::encode(buf);
|
|
||||||
state.write().await.bearer_token = token.clone();
|
state.write().await.bearer_token = token.clone();
|
||||||
|
|
||||||
let cancel = CancellationToken::new();
|
let cancel = CancellationToken::new();
|
||||||
|
|
@ -420,26 +505,49 @@ pub async fn start_server(
|
||||||
// Fresh service per session; cheap because we share state via Arcs.
|
// Fresh service per session; cheap because we share state via Arcs.
|
||||||
let ptys_f = ptys.clone();
|
let ptys_f = ptys.clone();
|
||||||
let state_f = state.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(
|
let mcp_service = StreamableHttpService::new(
|
||||||
move || Ok(TileService::new(ptys_f.clone(), state_f.clone())),
|
move || Ok(TileService::new(ptys_f.clone(), state_f.clone())),
|
||||||
LocalSessionManager::default().into(),
|
LocalSessionManager::default().into(),
|
||||||
Default::default(),
|
StreamableHttpServerConfig::default().disable_allowed_hosts(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let app = Router::new()
|
let router = Router::new()
|
||||||
.nest_service("/mcp", mcp_service)
|
.nest_service("/mcp", mcp_service)
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(
|
||||||
Arc::new(token.clone()),
|
Arc::new(token.clone()),
|
||||||
bearer_auth,
|
bearer_auth,
|
||||||
));
|
));
|
||||||
|
|
||||||
// Port 0 → OS picks. Recover via local_addr() before serving.
|
// Bind to all interfaces so WSL distros in NAT mode can reach the server
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
// via the Windows host's WSL-side adapter IP. Auth is bearer-token only.
|
||||||
let addr = listener.local_addr()?;
|
// 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 cancel_inner = cancel.clone();
|
||||||
let task = tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
let _ = axum::serve(listener, app)
|
let _ = axum::serve(listener, router)
|
||||||
.with_graceful_shutdown(async move {
|
.with_graceful_shutdown(async move {
|
||||||
cancel_inner.cancelled().await;
|
cancel_inner.cancelled().await;
|
||||||
})
|
})
|
||||||
|
|
@ -462,3 +570,13 @@ pub fn stop_server(handle: &McpServerHandle) {
|
||||||
tracing::info!("MCP server stopped");
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
16
src/App.tsx
16
src/App.tsx
|
|
@ -10,6 +10,7 @@ import {
|
||||||
mcpStart,
|
mcpStart,
|
||||||
mcpStop,
|
mcpStop,
|
||||||
mcpStatus as mcpStatusCmd,
|
mcpStatus as mcpStatusCmd,
|
||||||
|
mcpRegenerateToken,
|
||||||
mcpUpdateState,
|
mcpUpdateState,
|
||||||
writeToPane,
|
writeToPane,
|
||||||
killPane,
|
killPane,
|
||||||
|
|
@ -343,6 +344,20 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}, [notify]);
|
}, [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
|
// On mount, sync our local mcpStatus with whatever's already running
|
||||||
// (the backend persists state across HMR reloads).
|
// (the backend persists state across HMR reloads).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1051,6 +1066,7 @@ export default function App() {
|
||||||
status={mcpStatus}
|
status={mcpStatus}
|
||||||
onStart={startMcp}
|
onStart={startMcp}
|
||||||
onStop={stopMcp}
|
onStop={stopMcp}
|
||||||
|
onRegenerateToken={regenerateMcpToken}
|
||||||
onClose={() => setMcpPanelOpen(false)}
|
onClose={() => setMcpPanelOpen(false)}
|
||||||
allowedPaneCount={allowedPaneCount}
|
allowedPaneCount={allowedPaneCount}
|
||||||
totalPaneCount={leafCount(tree)}
|
totalPaneCount={leafCount(tree)}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface McpPanelProps {
|
||||||
status: McpStatus;
|
status: McpStatus;
|
||||||
onStart: () => Promise<void>;
|
onStart: () => Promise<void>;
|
||||||
onStop: () => Promise<void>;
|
onStop: () => Promise<void>;
|
||||||
|
onRegenerateToken: () => Promise<void>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** Count of leaves with mcpAllow=true — shown so the user knows whether
|
/** Count of leaves with mcpAllow=true — shown so the user knows whether
|
||||||
* enabling the server will actually expose anything. */
|
* enabling the server will actually expose anything. */
|
||||||
|
|
@ -21,12 +22,14 @@ export default function McpPanel({
|
||||||
status,
|
status,
|
||||||
onStart,
|
onStart,
|
||||||
onStop,
|
onStop,
|
||||||
|
onRegenerateToken,
|
||||||
onClose,
|
onClose,
|
||||||
allowedPaneCount,
|
allowedPaneCount,
|
||||||
totalPaneCount,
|
totalPaneCount,
|
||||||
}: McpPanelProps) {
|
}: McpPanelProps) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [revealToken, setRevealToken] = useState(false);
|
const [revealToken, setRevealToken] = useState(false);
|
||||||
|
const [regenBusy, setRegenBusy] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button className="backdrop" onClick={onClose} aria-label="Close" />
|
<button className="backdrop" onClick={onClose} aria-label="Close" />
|
||||||
|
|
@ -117,17 +134,30 @@ export default function McpPanel({
|
||||||
{revealToken ? "Hide" : "Show"}
|
{revealToken ? "Hide" : "Show"}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => copy(status.token!)}>Copy</button>
|
<button onClick={() => copy(status.token!)}>Copy</button>
|
||||||
|
<button onClick={regenerate} disabled={regenBusy}>
|
||||||
|
{regenBusy ? "…" : "Regenerate"}
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="mcp-field">
|
<div className="mcp-field">
|
||||||
<label>Claude config snippet</label>
|
<label>Claude Code config snippet (.mcp.json)</label>
|
||||||
<pre className="mcp-snippet">
|
<pre className="mcp-snippet">
|
||||||
{`{
|
{`{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"tiletopia": {
|
"tiletopia": {
|
||||||
"url": "${status.url}",
|
"command": "npx",
|
||||||
"headers": { "Authorization": "Bearer ${status.token}" }
|
"args": [
|
||||||
|
"-y", "mcp-remote",
|
||||||
|
"${status.url}",
|
||||||
|
"--allow-http",
|
||||||
|
"--header", "Authorization: Bearer ${status.token}"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`}
|
}`}
|
||||||
|
|
@ -139,10 +169,15 @@ export default function McpPanel({
|
||||||
{
|
{
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
tiletopia: {
|
tiletopia: {
|
||||||
url: status.url,
|
command: "npx",
|
||||||
headers: {
|
args: [
|
||||||
Authorization: `Bearer ${status.token}`,
|
"-y",
|
||||||
},
|
"mcp-remote",
|
||||||
|
status.url,
|
||||||
|
"--allow-http",
|
||||||
|
"--header",
|
||||||
|
`Authorization: Bearer ${status.token}`,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -157,14 +192,34 @@ export default function McpPanel({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mcp-tips">
|
<div className="mcp-tips">
|
||||||
<strong>WSL connectivity:</strong> for Claude running inside
|
<strong>Why the shim?</strong> Claude Code's HTTP-MCP
|
||||||
WSL to reach this server, enable mirrored networking in your
|
client tries OAuth discovery and ignores static{" "}
|
||||||
<code> %UserProfile%\.wslconfig</code> (Win11 22H2+):
|
<code>headers</code> auth (Anthropic issues #17152, #46879).
|
||||||
<pre>{`[wsl2]
|
The <code>mcp-remote</code> stdio shim transparently
|
||||||
networkingMode=mirrored`}</pre>
|
proxies the HTTP endpoint with the bearer header attached,
|
||||||
Then <code>127.0.0.1</code> in WSL routes to this Windows
|
which sidesteps the OAuth flow entirely. Other MCP
|
||||||
host. Without mirrored mode you'll need to use the WSL
|
clients that handle bearer auth correctly can connect
|
||||||
gateway IP.
|
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>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -178,11 +233,12 @@ networkingMode=mirrored`}</pre>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="mcp-security">
|
<p className="mcp-security">
|
||||||
<strong>Security:</strong> bound to 127.0.0.1 only. Anyone on
|
<strong>Security:</strong> bound to <code>0.0.0.0</code> so WSL
|
||||||
this machine running as you can read the bearer token if they
|
distros and other machines on your LAN can reach it; bearer
|
||||||
see it (e.g. via this UI or by guessing the localhost port).
|
token is the only thing keeping them out. Treat MCP access as
|
||||||
Treat MCP access as equivalent to terminal access. Saved SSH
|
equivalent to terminal access — don't share the token, don't
|
||||||
passwords are <em>never</em> exposed through MCP.
|
run the server on an untrusted network. Saved SSH passwords are{" "}
|
||||||
|
<em>never</em> exposed through MCP.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -130,5 +130,7 @@ export interface McpMirroredHost {
|
||||||
export const mcpStart = (): Promise<McpStatus> => invoke("mcp_start");
|
export const mcpStart = (): Promise<McpStatus> => invoke("mcp_start");
|
||||||
export const mcpStop = (): Promise<McpStatus> => invoke("mcp_stop");
|
export const mcpStop = (): Promise<McpStatus> => invoke("mcp_stop");
|
||||||
export const mcpStatus = (): Promise<McpStatus> => invoke("mcp_status");
|
export const mcpStatus = (): Promise<McpStatus> => invoke("mcp_status");
|
||||||
|
export const mcpRegenerateToken = (): Promise<McpStatus> =>
|
||||||
|
invoke("mcp_regenerate_token");
|
||||||
export const mcpUpdateState = (mirror: McpMirror): Promise<void> =>
|
export const mcpUpdateState = (mirror: McpMirror): Promise<void> =>
|
||||||
invoke("mcp_update_state", { mirror });
|
invoke("mcp_update_state", { mirror });
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,6 @@ export const TIPS: TipSpec[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "MCP server (let Claude drive the workspace)",
|
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).",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue