tiletopia/src/components/McpPanel.tsx
megaproxy 26ffe8859a MCP v2 PR-1b: action dispatcher, confirm modal, set_label end-to-end
App.tsx now listens on "mcp://request" and resolves each call:
needsConfirm=true queues a confirm modal (Accept/Reject, or
"Always allow <tool>" which appends the bare tool name to the
policy's allow bucket on the fly); needsConfirm=false runs straight
through. Replies via mcp_action_reply with externally-tagged
Result. The only wired-up tool for now is set_label, which delegates
to the existing ops.setLabel path.

McpConfirm.tsx (new) — themed amber-bordered modal sibling to the
existing overlays. Enter = accept, Esc = reject. Shows tool, the
policy reason that triggered the prompt, a human-readable summary
("Rename pane X → Y"), and an expandable raw-args section.

Audit log: subscription lifted from AuditTab up to App.tsx so events
fired while the panel is closed (or on Config/Policy tab) still land
in the ring. AuditTab becomes presentational; McpPanel forwards
entries + clearAudit + computes the unread badge from a baseline
seen-count.

StrictMode race fix: both new App-level listeners (mcp://audit and
mcp://request) use the cancelled-flag pattern so a late-resolving
listen() Promise after a strict-mode pretend-unmount tears itself
down instead of leaking a second subscription. Previously this
manifested as duplicate audit rows and a need-to-click-twice on
modal buttons.
2026-05-26 12:26:33 +01:00

305 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useCallback } from "react";
import {
writeText as clipboardWriteText,
} from "@tauri-apps/plugin-clipboard-manager";
import type { McpStatus, McpAuditEntry } from "../ipc";
import AuditTab from "./AuditTab";
import PolicyTab from "./PolicyTab";
import "./McpPanel.css";
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. */
allowedPaneCount: number;
/** Total pane count for context. */
totalPaneCount: number;
/** Persistent audit log, owned by App so it survives panel close. */
auditEntries: McpAuditEntry[];
onClearAudit: () => void;
}
type TabId = "config" | "audit" | "policy";
export default function McpPanel({
status,
onStart,
onStop,
onRegenerateToken,
onClose,
allowedPaneCount,
totalPaneCount,
auditEntries,
onClearAudit,
}: McpPanelProps) {
const [busy, setBusy] = useState(false);
const [revealToken, setRevealToken] = useState(false);
const [regenBusy, setRegenBusy] = useState(false);
const [tab, setTab] = useState<TabId>("config");
// Unread badge on Audit tab: count of entries arrived since the user last
// visited Audit. Tracked via a baseline count, reset on switch-to-audit.
const [auditSeenCount, setAuditSeenCount] = useState(auditEntries.length);
const auditUnread = auditEntries.length > auditSeenCount;
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),
);
}, []);
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]);
function switchTab(id: TabId) {
setTab(id);
if (id === "audit") setAuditSeenCount(auditEntries.length);
}
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>
{/* Tab bar */}
<div className="mcp-tabs" role="tablist">
<button
className={`mcp-tab${tab === "config" ? " mcp-tab--active" : ""}`}
role="tab"
aria-selected={tab === "config"}
onClick={() => switchTab("config")}
>
Config
</button>
<button
className={`mcp-tab${tab === "audit" ? " mcp-tab--active" : ""}`}
role="tab"
aria-selected={tab === "audit"}
onClick={() => switchTab("audit")}
>
Audit
{auditUnread && <span className="mcp-tab-badge" aria-label="new entries" />}
</button>
<button
className={`mcp-tab${tab === "policy" ? " mcp-tab--active" : ""}`}
role="tab"
aria-selected={tab === "policy"}
onClick={() => switchTab("policy")}
>
Policy
</button>
</div>
<div className="mcp-body">
{tab === "config" && (
<>
<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={() => { void 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>
<button onClick={() => { void 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 Code config snippet (.mcp.json)</label>
<pre className="mcp-snippet">
{`{
"mcpServers": {
"tiletopia": {
"command": "npx",
"args": [
"-y", "mcp-remote",
"${status.url}",
"--allow-http",
"--header", "Authorization: Bearer ${status.token}"
]
}
}
}`}
</pre>
<button
onClick={() =>
copy(
JSON.stringify(
{
mcpServers: {
tiletopia: {
command: "npx",
args: [
"-y",
"mcp-remote",
status.url,
"--allow-http",
"--header",
`Authorization: Bearer ${status.token}`,
],
},
},
},
null,
2,
),
)
}
>
Copy config snippet
</button>
</div>
<div className="mcp-tips">
<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>
</>
)}
{!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 <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>
</>
)}
{tab === "audit" && (
<AuditTab entries={auditEntries} onClear={onClearAudit} />
)}
{tab === "policy" && <PolicyTab />}
</div>
</div>
</>
);
}