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
42
README.md
42
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 <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
16
src/App.tsx
16
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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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).",
|
||||
},
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue