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.
This commit is contained in:
megaproxy 2026-05-26 12:26:33 +01:00
parent 464c576b79
commit 26ffe8859a
6 changed files with 397 additions and 49 deletions

View file

@ -1,9 +1,5 @@
import { useEffect, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import type { McpAuditEntry } from "../ipc";
const RING_CAP = 200;
function fmtTime(tsMs: number): string {
const d = new Date(tsMs);
const hh = String(d.getHours()).padStart(2, "0");
@ -38,49 +34,18 @@ function rowClass(result: McpAuditEntry["result"]): string {
}
interface AuditTabProps {
/** Called when there are unread entries (tab not active). */
onUnread?: () => void;
/** True when this tab is the currently visible tab — clears unread. */
active?: boolean;
/** Audit ring, owned by App so it persists across panel open/close. */
entries: McpAuditEntry[];
onClear: () => void;
}
export default function AuditTab({ onUnread, active }: AuditTabProps) {
const [entries, setEntries] = useState<McpAuditEntry[]>([]);
const [unread, setUnread] = useState(0);
useEffect(() => {
let unlisten: UnlistenFn | undefined;
void listen<McpAuditEntry>("mcp://audit", (e) => {
setEntries((prev) => {
const next = [e.payload, ...prev];
return next.length > RING_CAP ? next.slice(0, RING_CAP) : next;
});
if (!active) {
setUnread((n) => n + 1);
onUnread?.();
}
}).then((fn) => {
unlisten = fn;
});
return () => {
if (unlisten) unlisten();
};
}, [active, onUnread]);
// Clear unread badge when tab becomes active.
useEffect(() => {
if (active) setUnread(0);
}, [active]);
export default function AuditTab({ entries, onClear }: AuditTabProps) {
return (
<div className="audit-tab">
<div className="audit-toolbar">
{unread > 0 && !active && (
<span className="audit-unread">{unread} new</span>
)}
<button
className="audit-clear"
onClick={() => setEntries([])}
onClick={onClear}
disabled={entries.length === 0}
>
Clear

View file

@ -0,0 +1,81 @@
import { useEffect } from "react";
export interface McpConfirmSpec {
tool: string;
args: unknown;
reason: string | null;
/** Human-readable summary of what's about to happen, computed by the
* per-tool handler (e.g. "rename pane 'shell' to 'build'"). */
summary: string;
}
interface McpConfirmProps {
spec: McpConfirmSpec;
onAccept: () => void;
onReject: () => void;
/** Approve this call AND add the bare tool name to the policy allow list
* so future calls of this tool skip the prompt. */
onAlwaysAllow: () => void | Promise<void>;
}
export default function McpConfirm({ spec, onAccept, onReject, onAlwaysAllow }: McpConfirmProps) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault();
onReject();
} else if (e.key === "Enter") {
e.preventDefault();
onAccept();
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onAccept, onReject]);
const argsJson = JSON.stringify(spec.args, null, 2);
return (
<>
<button
className="backdrop"
onClick={onReject}
aria-label="Reject MCP action"
/>
<div className="mcp-confirm" role="dialog" aria-label="MCP action confirm">
<header className="mcp-confirm-header">
<span className="mcp-confirm-title">
MCP wants to run <code>{spec.tool}</code>
</span>
</header>
<div className="mcp-confirm-body">
<p className="mcp-confirm-summary">{spec.summary}</p>
{spec.reason && (
<p className="mcp-confirm-reason">
Policy decision: <em>{spec.reason}</em>
</p>
)}
<details className="mcp-confirm-args">
<summary>Raw arguments</summary>
<pre>{argsJson}</pre>
</details>
</div>
<footer className="mcp-confirm-actions">
<button className="mcp-confirm-reject" onClick={onReject}>
Reject (Esc)
</button>
<button
className="mcp-confirm-always"
onClick={() => { void onAlwaysAllow(); }}
title={`Add "${spec.tool}" to the policy allow list — future calls of this tool won't prompt`}
>
Always allow {spec.tool}
</button>
<button className="mcp-confirm-accept" onClick={onAccept} autoFocus>
Approve (Enter)
</button>
</footer>
</div>
</>
);
}

View file

@ -596,3 +596,117 @@
margin: 8px 0 0;
line-height: 1.4;
}
/* ---- Confirm modal ------------------------------------------------------ */
.mcp-confirm {
position: fixed;
top: 20vh;
left: 50%;
transform: translateX(-50%);
width: min(520px, 92vw);
max-height: 60vh;
background: #161616;
color: #ccc;
border: 1px solid #c09040;
border-radius: 8px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7);
z-index: 200;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
}
.mcp-confirm-header {
padding: 10px 14px;
border-bottom: 1px solid #2a2a2a;
background: linear-gradient(180deg, #2a2010, #161616);
}
.mcp-confirm-title { font-size: 13px; font-weight: 600; }
.mcp-confirm-title code {
color: #c09040;
background: transparent;
font-size: 12px;
}
.mcp-confirm-body {
padding: 14px 16px;
overflow-y: auto;
font-size: 12px;
line-height: 1.5;
scrollbar-width: thin;
scrollbar-color: #2a2a2a transparent;
}
.mcp-confirm-body::-webkit-scrollbar { width: 8px; }
.mcp-confirm-body::-webkit-scrollbar-thumb {
background: #2a2a2a;
border-radius: 4px;
border: 1px solid #1a1a1a;
}
.mcp-confirm-summary { margin: 0 0 8px; color: #ddd; }
.mcp-confirm-reason { margin: 0 0 8px; color: #888; font-size: 11px; }
.mcp-confirm-reason em { color: #c09040; font-style: normal; }
.mcp-confirm-args {
margin-top: 10px;
font-size: 11px;
}
.mcp-confirm-args summary {
color: #888;
cursor: pointer;
user-select: none;
padding: 2px 0;
}
.mcp-confirm-args summary:hover { color: #aaa; }
.mcp-confirm-args pre {
background: #0c0c0c;
border: 1px solid #2a2a2a;
border-radius: 3px;
padding: 8px;
margin: 6px 0 0;
color: #aaa;
font-size: 11px;
overflow-x: auto;
white-space: pre-wrap;
}
.mcp-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid #2a2a2a;
background: #111;
}
.mcp-confirm-reject,
.mcp-confirm-accept {
font: inherit;
font-size: 12px;
padding: 5px 14px;
border-radius: 3px;
cursor: pointer;
border: 1px solid #2a2a3a;
}
.mcp-confirm-reject { background: #1a1a1a; color: #aaa; }
.mcp-confirm-reject:hover { background: #2a1a1a; color: #e08080; border-color: #4a2020; }
.mcp-confirm-accept { background: #1a2a1a; color: #80c080; border-color: #2a4a2a; }
.mcp-confirm-accept:hover { background: #2a4a2a; color: #a0e0a0; }
.mcp-confirm-always {
font: inherit;
font-size: 12px;
padding: 5px 14px;
border-radius: 3px;
cursor: pointer;
background: #1a1a2a;
color: #aac;
border: 1px solid #2a2a4a;
margin-right: auto;
}
.mcp-confirm-always:hover {
background: #2a2a4a;
color: #ccd;
border-color: #4488cc;
}

View file

@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react";
import {
writeText as clipboardWriteText,
} from "@tauri-apps/plugin-clipboard-manager";
import type { McpStatus } from "../ipc";
import type { McpStatus, McpAuditEntry } from "../ipc";
import AuditTab from "./AuditTab";
import PolicyTab from "./PolicyTab";
import "./McpPanel.css";
@ -18,6 +18,9 @@ interface McpPanelProps {
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";
@ -30,12 +33,17 @@ export default function McpPanel({
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");
const [auditUnread, setAuditUnread] = useState(false);
// 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) {
@ -81,7 +89,7 @@ export default function McpPanel({
function switchTab(id: TabId) {
setTab(id);
if (id === "audit") setAuditUnread(false);
if (id === "audit") setAuditSeenCount(auditEntries.length);
}
return (
@ -286,10 +294,7 @@ export default function McpPanel({
)}
{tab === "audit" && (
<AuditTab
active={tab === "audit"}
onUnread={() => setAuditUnread(true)}
/>
<AuditTab entries={auditEntries} onClear={onClearAudit} />
)}
{tab === "policy" && <PolicyTab />}