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

View file

@ -56,6 +56,11 @@
background: #2a2010;
color: #c98a1f;
}
.palette-btn.mcp-btn.on {
background: #1a3a1a;
color: #80e080;
border-color: #2a6a2a;
}
.preset-btn {
min-width: 28px;
text-align: center;

View file

@ -7,10 +7,18 @@ import {
saveSshHosts,
setHostPassword,
deleteHostPassword,
mcpStart,
mcpStop,
mcpStatus as mcpStatusCmd,
mcpUpdateState,
writeToPane,
killPane,
type PaneId,
type SshHost,
type McpStatus,
type McpMirror,
type McpMirroredLeaf,
type McpMirroredHost,
} from "./ipc";
import {
type TreeNode,
@ -27,6 +35,7 @@ import {
setLeafShell,
changeLabel,
toggleBroadcast as toggleBroadcastInTree,
toggleMcpAllow as toggleMcpAllowInTree,
setAllBroadcast,
adjustFontSize,
adjustAllFontSizes,
@ -53,6 +62,7 @@ import Notifications, { type Toast } from "./components/Notifications";
import Palette from "./components/Palette";
import HostManager from "./components/HostManager";
import Help from "./components/Help";
import McpPanel from "./components/McpPanel";
import "./App.css";
import "./lib/layout/Gutter.css";
@ -86,6 +96,12 @@ export default function App() {
const [hosts, setHosts] = useState<SshHost[]>([]);
const [hostManagerOpen, setHostManagerOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [mcpStatus, setMcpStatus] = useState<McpStatus>({
running: false,
url: null,
token: null,
});
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
const [ready, setReady] = useState(false);
const [notifications, setNotifications] = useState<Toast[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
@ -261,6 +277,46 @@ export default function App() {
setTree((t) => toggleBroadcastInTree(t, leafId));
}, []);
const toggleMcpAllow = useCallback((leafId: NodeId) => {
setTree((t) => toggleMcpAllowInTree(t, leafId));
}, []);
// ---- MCP server lifecycle ------------------------------------------------
const refreshMcpStatus = useCallback(async () => {
try {
const st = await mcpStatusCmd();
setMcpStatus(st);
} catch (e) {
console.warn("mcpStatus failed:", e);
}
}, []);
const startMcp = useCallback(async () => {
try {
const st = await mcpStart();
setMcpStatus(st);
notify("MCP server started — see panel for URL + token");
} catch (e) {
notify(`MCP start failed: ${e}`);
}
}, [notify]);
const stopMcp = useCallback(async () => {
try {
const st = await mcpStop();
setMcpStatus(st);
notify("MCP server stopped");
} catch (e) {
notify(`MCP stop failed: ${e}`);
}
}, [notify]);
// On mount, sync our local mcpStatus with whatever's already running
// (the backend persists state across HMR reloads).
useEffect(() => {
void refreshMcpStatus();
}, [refreshMcpStatus]);
// Ctrl+Shift+P: pop the active leaf out one level. The keyboard
// replacement for the (removed) drag-past-sibling gesture. No-op with a
// toast if the leaf is at the root or its parent shares orientation
@ -532,6 +588,7 @@ export default function App() {
setShell,
setLabel,
toggleBroadcast,
toggleMcpAllow,
openHostManager,
setActive,
registerPaneId,
@ -553,6 +610,7 @@ export default function App() {
setShell,
setLabel,
toggleBroadcast,
toggleMcpAllow,
openHostManager,
setActive,
registerPaneId,
@ -567,6 +625,47 @@ export default function App() {
],
);
// ---- MCP mirror push -----------------------------------------------------
// Whenever the tree, hosts, or active selection change AND the MCP server
// is running, push a fresh mirror down to the backend. Per-leaf mcpAllow
// gates whether each leaf appears in the mirror (default-deny).
const allowedPaneCount = useMemo(
() => Array.from(walkLeaves(tree)).filter((l) => l.mcpAllow).length,
[tree],
);
useEffect(() => {
if (!mcpStatus.running) return;
const leaves: Record<string, McpMirroredLeaf> = {};
for (const leaf of walkLeaves(tree)) {
if (!leaf.mcpAllow) continue;
leaves[leaf.id] = {
paneId: paneIdByLeafRef.current.get(leaf.id) ?? null,
label: leaf.label,
shellKind: leaf.shellKind,
distro: leaf.distro,
sshHostId: leaf.sshHostId,
broadcast: !!leaf.broadcast,
active: activeLeafId === leaf.id,
};
}
const mirroredHosts: McpMirroredHost[] = hosts.map((h) => ({
id: h.id,
label: h.label,
hostname: h.hostname,
user: h.user,
port: h.port,
hasPassword: !!h.hasPassword,
}));
const mirror: McpMirror = {
layoutJson: serialize(tree),
leaves,
hosts: mirroredHosts,
};
mcpUpdateState(mirror).catch((e) =>
console.warn("mcpUpdateState failed:", e),
);
}, [mcpStatus.running, tree, hosts, activeLeafId]);
const applyPreset = useCallback(
(make: (d: Partial<LeafNode>) => TreeNode) => {
const { tree: nextTree, dropped } = reshapeToPreset(
@ -723,6 +822,19 @@ export default function App() {
>
🔔
</button>
<button
className={`palette-btn mcp-btn${mcpStatus.running ? " on" : ""}`}
onClick={() => setMcpPanelOpen(true)}
title={
mcpStatus.running
? `MCP server running (${allowedPaneCount} of ${leafCount(tree)} panes visible) — click for details`
: "MCP server is OFF — click to configure / start"
}
aria-label="MCP server"
aria-pressed={mcpStatus.running ? "true" : "false"}
>
🤖
</button>
<button
className="palette-btn"
onClick={() => setHelpOpen(true)}
@ -794,6 +906,17 @@ export default function App() {
)}
{helpOpen && <Help onClose={() => setHelpOpen(false)} />}
{mcpPanelOpen && (
<McpPanel
status={mcpStatus}
onStart={startMcp}
onStop={stopMcp}
onClose={() => setMcpPanelOpen(false)}
allowedPaneCount={allowedPaneCount}
totalPaneCount={leafCount(tree)}
/>
)}
</div>
);
}

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

@ -0,0 +1,191 @@
.mcp-panel {
position: fixed;
top: 8vh;
left: 50%;
transform: translateX(-50%);
width: min(680px, 92vw);
max-height: 84vh;
background: #161616;
color: #ccc;
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.mcp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
}
.mcp-title { font-weight: 600; font-size: 13px; }
.mcp-close {
background: transparent; border: none; color: #888;
font-size: 18px; line-height: 1; padding: 2px 8px;
cursor: pointer; border-radius: 3px;
}
.mcp-close:hover { background: #2a2a2a; color: #ddd; }
.mcp-body {
padding: 14px 18px;
overflow-y: auto;
font-size: 12px;
line-height: 1.45;
}
.mcp-blurb {
color: #aaa;
margin: 0 0 12px;
}
.mcp-toggle-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.mcp-toggle {
font: inherit;
font-family: inherit;
font-size: 12px;
font-weight: 600;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
background: #222;
color: #999;
border: 1px solid #2a2a2a;
display: inline-flex;
align-items: center;
gap: 8px;
}
.mcp-toggle:hover:not(:disabled) { background: #2a2a2a; color: #ddd; }
.mcp-toggle:disabled { opacity: 0.5; cursor: progress; }
.mcp-toggle.on {
background: #1a3a1a;
color: #80e080;
border-color: #2a6a2a;
}
.mcp-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #555;
}
.mcp-toggle.on .mcp-dot {
background: #80e080;
box-shadow: 0 0 6px rgba(128, 224, 128, 0.6);
}
.mcp-allow-count {
color: #888;
font-size: 11px;
}
.mcp-allow-warn {
color: #d8a040;
}
.mcp-field {
margin-bottom: 12px;
}
.mcp-field label {
display: block;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #777;
margin-bottom: 3px;
}
.mcp-field-row {
display: flex;
gap: 6px;
}
.mcp-field input {
flex: 1 1 auto;
font: inherit;
font-family: inherit;
font-size: 12px;
color: #e6e6e6;
background: #0c0c0c;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 4px 8px;
outline: none;
}
.mcp-field button {
font: inherit;
font-family: inherit;
font-size: 11px;
background: #222;
color: #aac;
border: 1px solid #2a2a3a;
border-radius: 3px;
padding: 0 10px;
cursor: pointer;
}
.mcp-field button:hover {
background: #2a2a3a;
color: #ccd;
}
.mcp-snippet {
font: inherit;
font-family: inherit;
font-size: 11px;
background: #0c0c0c;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 8px 10px;
margin: 0 0 6px;
color: #cce6ff;
white-space: pre-wrap;
word-break: break-all;
}
.mcp-tips {
background: #1a2030;
border: 1px solid #2a3040;
border-radius: 4px;
padding: 10px 12px;
color: #aac;
font-size: 11px;
margin: 12px 0;
}
.mcp-tips strong { color: #cce6ff; }
.mcp-tips code {
background: #0c0c0c;
padding: 1px 4px;
border-radius: 2px;
font-family: inherit;
}
.mcp-tips pre {
font: inherit;
font-family: inherit;
background: #0c0c0c;
padding: 6px 8px;
border-radius: 3px;
margin: 4px 0;
color: #cce6ff;
}
.mcp-off-hint {
color: #888;
font-size: 11px;
font-style: italic;
margin: 8px 0 12px;
}
.mcp-security {
margin: 12px 0 0;
padding-top: 10px;
border-top: 1px solid #2a2a2a;
color: #888;
font-size: 11px;
line-height: 1.45;
}
.mcp-security strong { color: #d8a040; }
.mcp-security em { color: #d88; font-style: normal; }

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>
</>
);
}

View file

@ -90,3 +90,45 @@ export const deleteHostPassword = (hostId: string): Promise<void> =>
export const hasHostPassword = (hostId: string): Promise<boolean> =>
invoke("has_host_password", { hostId });
// ---- MCP server -----------------------------------------------------------
export interface McpStatus {
running: boolean;
url: string | null;
token: string | null;
}
/** Shape of the cached mirror we push to the backend on every workspace
* change. Mirrors src-tauri/src/mcp.rs `McpMirror`. */
export interface McpMirror {
layoutJson: string;
/** Only includes leaves with mcpAllow === true. */
leaves: Record<string, McpMirroredLeaf>;
hosts: McpMirroredHost[];
}
export interface McpMirroredLeaf {
paneId: number | null;
label?: string;
shellKind: "wsl" | "powershell" | "ssh";
distro?: string;
sshHostId?: string;
broadcast: boolean;
active: boolean;
}
export interface McpMirroredHost {
id: string;
label: string;
hostname: string;
user?: string;
port?: number;
hasPassword: boolean;
}
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 mcpUpdateState = (mirror: McpMirror): Promise<void> =>
invoke("mcp_update_state", { mirror });

View file

@ -123,6 +123,12 @@
color: #f0c060;
border-color: #c98a1f;
}
.bcast-chip.mcp-chip.on {
/* Green for MCP-allowed — clearly distinct from broadcast's orange. */
background: #1a3a1a;
color: #80e080;
border-color: #2a6a2a;
}
.distro-menu {
position: absolute;

View file

@ -418,6 +418,22 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
📡
</button>
<button
className={`bcast-chip mcp-chip${leaf.mcpAllow ? " on" : ""}`}
onClick={(e) => {
e.stopPropagation();
orch.toggleMcpAllow(leaf.id);
}}
title={
leaf.mcpAllow
? "MCP can see this pane — click to revoke"
: "MCP cannot see this pane — click to allow (only matters when the MCP server is on)"
}
aria-pressed={leaf.mcpAllow ? "true" : "false"}
>
🤖
</button>
{isIdle && statusOk ? (
<span className="pane-status idle" title={`No output for ${IDLE_THRESHOLD_MS / 1000}s+`}>
idle

View file

@ -31,6 +31,9 @@ export interface Orchestration {
setShell: (leafId: NodeId, spec: LeafShellSpec) => void;
setLabel: (leafId: NodeId, label: string | undefined) => void;
toggleBroadcast: (leafId: NodeId) => void;
/** Flip the per-pane mcpAllow flag. Default-deny; chip in the pane
* toolbar drives this. */
toggleMcpAllow: (leafId: NodeId) => void;
// SSH host management
openHostManager: () => void;

View file

@ -108,4 +108,8 @@ export const TIPS: TipSpec[] = [
title: "Workspace persistence",
body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.",
},
{
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.",
},
];