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

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