Add MCP server (v1 read-only): toggle, per-pane gate, panel UI

This commit is contained in:
megaproxy 2026-05-25 21:31:49 +01:00
parent 6068522ee3
commit 83d8932c98
15 changed files with 1235 additions and 7 deletions

191
src/components/McpPanel.tsx Normal file
View file

@ -0,0 +1,191 @@
import { useEffect, useState, useCallback } from "react";
import {
writeText as clipboardWriteText,
} from "@tauri-apps/plugin-clipboard-manager";
import type { McpStatus } from "../ipc";
import "./McpPanel.css";
interface McpPanelProps {
status: McpStatus;
onStart: () => Promise<void>;
onStop: () => Promise<void>;
onClose: () => void;
/** Count of leaves with mcpAllow=true shown so the user knows whether
* enabling the server will actually expose anything. */
allowedPaneCount: number;
/** Total pane count for context. */
totalPaneCount: number;
}
export default function McpPanel({
status,
onStart,
onStop,
onClose,
allowedPaneCount,
totalPaneCount,
}: McpPanelProps) {
const [busy, setBusy] = useState(false);
const [revealToken, setRevealToken] = useState(false);
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const toggle = useCallback(async () => {
if (busy) return;
setBusy(true);
try {
if (status.running) await onStop();
else await onStart();
} finally {
setBusy(false);
}
}, [busy, status.running, onStart, onStop]);
const copy = useCallback((s: string) => {
void clipboardWriteText(s).catch((e) =>
console.warn("clipboard write failed:", e),
);
}, []);
return (
<>
<button className="backdrop" onClick={onClose} aria-label="Close" />
<div className="mcp-panel" role="dialog" aria-label="MCP server">
<header className="mcp-header">
<span className="mcp-title">MCP server</span>
<button className="mcp-close" onClick={onClose} aria-label="Close">×</button>
</header>
<div className="mcp-body">
<p className="mcp-blurb">
Lets a Claude session on the same machine inspect this workspace
via Model Context Protocol see which panes are running, read
their scrollback, wait for commands to settle. Read-only in v1;
Claude can't send keystrokes or reshape the layout yet.
</p>
<div className="mcp-toggle-row">
<button
className={`mcp-toggle${status.running ? " on" : ""}`}
onClick={toggle}
disabled={busy}
>
<span className="mcp-dot" />
{status.running ? "Server: ON" : "Server: OFF"}
</button>
<span className="mcp-allow-count">
{allowedPaneCount} of {totalPaneCount} pane
{totalPaneCount === 1 ? "" : "s"} allow-listed
{allowedPaneCount === 0 && status.running && (
<span className="mcp-allow-warn">
{" "}
Claude will see nothing until you toggle 🤖 on at least
one pane.
</span>
)}
</span>
</div>
{status.running && status.url && status.token && (
<>
<div className="mcp-field">
<label>URL</label>
<div className="mcp-field-row">
<input readOnly value={status.url} onFocus={(e) => e.currentTarget.select()} />
<button onClick={() => copy(status.url!)}>Copy</button>
</div>
</div>
<div className="mcp-field">
<label>Bearer token</label>
<div className="mcp-field-row">
<input
readOnly
type={revealToken ? "text" : "password"}
value={status.token}
onFocus={(e) => e.currentTarget.select()}
/>
<button onClick={() => setRevealToken((r) => !r)}>
{revealToken ? "Hide" : "Show"}
</button>
<button onClick={() => copy(status.token!)}>Copy</button>
</div>
</div>
<div className="mcp-field">
<label>Claude config snippet</label>
<pre className="mcp-snippet">
{`{
"mcpServers": {
"tiletopia": {
"url": "${status.url}",
"headers": { "Authorization": "Bearer ${status.token}" }
}
}
}`}
</pre>
<button
onClick={() =>
copy(
JSON.stringify(
{
mcpServers: {
tiletopia: {
url: status.url,
headers: {
Authorization: `Bearer ${status.token}`,
},
},
},
},
null,
2,
),
)
}
>
Copy config snippet
</button>
</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.
</div>
</>
)}
{!status.running && (
<p className="mcp-off-hint">
Server is off no port is open. Token is generated when you
start. Each pane needs the 🤖 chip toggled on for Claude to
see it.
</p>
)}
<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.
</p>
</div>
</div>
</>
);
}