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; onStop: () => Promise; onRegenerateToken: () => Promise; 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("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 ( <> {/* Tab bar */}
{tab === "config" && ( <>

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.

{allowedPaneCount} of {totalPaneCount} pane {totalPaneCount === 1 ? "" : "s"} allow-listed {allowedPaneCount === 0 && status.running && ( {" "} — Claude will see nothing until you toggle 🤖 on at least one pane. )}
{status.running && status.url && status.token && ( <>
e.currentTarget.select()} />
e.currentTarget.select()} />

URL + token persist across restarts — paste the snippet into your Claude config once. Regenerate if the token leaks.

{`{
  "mcpServers": {
    "tiletopia": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote",
        "${status.url}",
        "--allow-http",
        "--header", "Authorization: Bearer ${status.token}"
      ]
    }
  }
}`}
                    
Why the shim? Claude Code's HTTP-MCP client tries OAuth discovery and ignores static{" "} headers auth (Anthropic issues #17152, #46879). The mcp-remote 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{" "} Authorization header.

WSL connectivity: the URL uses{" "} 127.0.0.1; a Claude session running inside WSL needs to either swap that for the WSL gateway IP (ip route show default | awk '{`{print $3}`}'{" "} inside WSL — changes after each WSL restart), or enable mirrored networking (networkingMode=mirrored{" "} in %UserProfile%\.wslconfig, Win11 22H2+) so 127.0.0.1 in WSL routes to this host. You'll likely also need to allow the port through Windows Defender Firewall:{" "} New-NetFirewallRule -DisplayName 'tiletopia MCP' -Direction Inbound -Action Allow -Protocol TCP -LocalPort {status.url.match(/:(\d+)\//)?.[1] ?? "47821"}{" "} -Profile Any {" "} (elevated PowerShell).
)} {!status.running && (

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.

)}

Security: bound to 0.0.0.0 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{" "} never exposed through MCP.

)} {tab === "audit" && ( )} {tab === "policy" && }
); }