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

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