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.
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|