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:
parent
464c576b79
commit
26ffe8859a
6 changed files with 397 additions and 49 deletions
170
src/App.tsx
170
src/App.tsx
|
|
@ -12,6 +12,10 @@ import {
|
||||||
mcpStatus as mcpStatusCmd,
|
mcpStatus as mcpStatusCmd,
|
||||||
mcpRegenerateToken,
|
mcpRegenerateToken,
|
||||||
mcpUpdateState,
|
mcpUpdateState,
|
||||||
|
onMcpRequest,
|
||||||
|
mcpActionReply,
|
||||||
|
mcpPolicyLoad,
|
||||||
|
mcpPolicySave,
|
||||||
writeToPane,
|
writeToPane,
|
||||||
killPane,
|
killPane,
|
||||||
type PaneId,
|
type PaneId,
|
||||||
|
|
@ -20,7 +24,10 @@ import {
|
||||||
type McpMirror,
|
type McpMirror,
|
||||||
type McpMirroredLeaf,
|
type McpMirroredLeaf,
|
||||||
type McpMirroredHost,
|
type McpMirroredHost,
|
||||||
|
type McpActionRequest,
|
||||||
|
type McpAuditEntry,
|
||||||
} from "./ipc";
|
} from "./ipc";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import {
|
import {
|
||||||
type TreeNode,
|
type TreeNode,
|
||||||
type NodeId,
|
type NodeId,
|
||||||
|
|
@ -64,6 +71,7 @@ import Palette from "./components/Palette";
|
||||||
import HostManager from "./components/HostManager";
|
import HostManager from "./components/HostManager";
|
||||||
import Help from "./components/Help";
|
import Help from "./components/Help";
|
||||||
import McpPanel from "./components/McpPanel";
|
import McpPanel from "./components/McpPanel";
|
||||||
|
import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import "./lib/layout/Gutter.css";
|
import "./lib/layout/Gutter.css";
|
||||||
|
|
||||||
|
|
@ -728,6 +736,154 @@ export default function App() {
|
||||||
);
|
);
|
||||||
}, [mcpStatus.running, tree, hosts, activeLeafId]);
|
}, [mcpStatus.running, tree, hosts, activeLeafId]);
|
||||||
|
|
||||||
|
// ---- MCP audit log -------------------------------------------------------
|
||||||
|
// Subscribed at App level so events received while the panel is closed (or
|
||||||
|
// while the user is on another tab) still land in the ring buffer. The
|
||||||
|
// McpPanel consumes the entries via props.
|
||||||
|
const AUDIT_RING_CAP = 200;
|
||||||
|
const [auditEntries, setAuditEntries] = useState<McpAuditEntry[]>([]);
|
||||||
|
const clearAudit = useCallback(() => setAuditEntries([]), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// StrictMode double-mounts effects in dev; the listen() Promise may
|
||||||
|
// resolve AFTER the first cleanup runs. Guard with a cancelled flag so
|
||||||
|
// a late-resolving subscription gets immediately torn down instead of
|
||||||
|
// leaking into the second mount (which would double every event).
|
||||||
|
let cancelled = false;
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
void listen<McpAuditEntry>("mcp://audit", (e) => {
|
||||||
|
setAuditEntries((prev) => {
|
||||||
|
const next = [e.payload, ...prev];
|
||||||
|
return next.length > AUDIT_RING_CAP ? next.slice(0, AUDIT_RING_CAP) : next;
|
||||||
|
});
|
||||||
|
}).then((fn) => {
|
||||||
|
if (cancelled) fn();
|
||||||
|
else unlisten = fn;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- MCP action dispatcher -----------------------------------------------
|
||||||
|
// Backend tools that mutate workspace state emit "mcp://request" events with
|
||||||
|
// a requestId + args. We resolve them via mcp_action_reply(requestId, Ok|Err).
|
||||||
|
// When needsConfirm is true, a modal queues up first; user Accept = run,
|
||||||
|
// Reject = reply with Err. Per-tool handlers live in `runMcpHandler` below.
|
||||||
|
|
||||||
|
interface ConfirmEntry extends McpConfirmSpec {
|
||||||
|
resolve: (accept: boolean) => void;
|
||||||
|
}
|
||||||
|
const [confirmQueue, setConfirmQueue] = useState<ConfirmEntry[]>([]);
|
||||||
|
|
||||||
|
const requestConfirm = useCallback(
|
||||||
|
(spec: McpConfirmSpec): Promise<boolean> =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
setConfirmQueue((q) => [...q, { ...spec, resolve }]),
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dismissConfirm = useCallback((accept: boolean) => {
|
||||||
|
setConfirmQueue((q) => {
|
||||||
|
const [head, ...rest] = q;
|
||||||
|
if (head) head.resolve(accept);
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// "Always allow" from the confirm modal: appends the bare tool name to
|
||||||
|
// the policy's allow bucket so future calls of this tool skip the prompt.
|
||||||
|
// Idempotent — duplicate adds are no-ops.
|
||||||
|
const alwaysAllowTool = useCallback(async (tool: string) => {
|
||||||
|
try {
|
||||||
|
const policy = await mcpPolicyLoad();
|
||||||
|
if (!policy.permissions.allow.includes(tool)) {
|
||||||
|
policy.permissions.allow.push(tool);
|
||||||
|
await mcpPolicySave(policy);
|
||||||
|
notify(`Added "${tool}" to MCP allow list — future calls won't prompt`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notify(`Failed to update MCP policy: ${e}`);
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
// Per-tool handler. Each branch validates args, applies the mutation via the
|
||||||
|
// same setters the UI uses, and returns a JSON-serialisable payload. Throws
|
||||||
|
// on validation failure — caller converts to Err reply.
|
||||||
|
const runMcpHandler = useCallback(
|
||||||
|
async (tool: string, args: unknown): Promise<{ payload: unknown; summary: string }> => {
|
||||||
|
switch (tool) {
|
||||||
|
case "set_label": {
|
||||||
|
const a = args as { leafId?: string; label?: string };
|
||||||
|
if (typeof a.leafId !== "string") throw new Error("missing leafId");
|
||||||
|
if (typeof a.label !== "string") throw new Error("missing label");
|
||||||
|
const leaf = findLeaf(treeRef.current, a.leafId);
|
||||||
|
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
|
||||||
|
const before = leaf.label ?? "(unlabelled)";
|
||||||
|
setLabel(a.leafId, a.label || undefined);
|
||||||
|
const after = a.label || "(cleared)";
|
||||||
|
return {
|
||||||
|
payload: { leafId: a.leafId, label: a.label },
|
||||||
|
summary: `Rename pane "${before}" → "${after}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`unsupported MCP tool: ${tool}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setLabel],
|
||||||
|
);
|
||||||
|
|
||||||
|
// The summary string for the confirm modal needs access to the leaf
|
||||||
|
// metadata, so we compute it up-front by partially running the handler
|
||||||
|
// logic (without mutating). For now we just rebuild it inline per tool;
|
||||||
|
// when more tools land this should split out.
|
||||||
|
const buildConfirmSummary = useCallback((tool: string, args: unknown): string => {
|
||||||
|
if (tool === "set_label") {
|
||||||
|
const a = args as { leafId?: string; label?: string };
|
||||||
|
const leaf = a.leafId ? findLeaf(treeRef.current, a.leafId) : null;
|
||||||
|
const before = leaf && leaf.kind === "leaf" ? (leaf.label ?? "(unlabelled)") : "(unknown)";
|
||||||
|
const after = a.label || "(cleared)";
|
||||||
|
return `Rename pane "${before}" → "${after}"`;
|
||||||
|
}
|
||||||
|
return `Run ${tool}`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let unlisten: (() => void) | undefined;
|
||||||
|
void onMcpRequest(async (req: McpActionRequest) => {
|
||||||
|
try {
|
||||||
|
if (req.needsConfirm) {
|
||||||
|
const summary = buildConfirmSummary(req.tool, req.args);
|
||||||
|
const ok = await requestConfirm({
|
||||||
|
tool: req.tool,
|
||||||
|
args: req.args,
|
||||||
|
reason: req.reason,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
await mcpActionReply(req.requestId, { Err: "user rejected" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { payload } = await runMcpHandler(req.tool, req.args);
|
||||||
|
await mcpActionReply(req.requestId, { Ok: payload });
|
||||||
|
} catch (err) {
|
||||||
|
await mcpActionReply(req.requestId, { Err: String(err) });
|
||||||
|
}
|
||||||
|
}).then((fn) => {
|
||||||
|
if (cancelled) fn();
|
||||||
|
else unlisten = fn;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
};
|
||||||
|
}, [runMcpHandler, requestConfirm, buildConfirmSummary]);
|
||||||
|
|
||||||
const applyPreset = useCallback(
|
const applyPreset = useCallback(
|
||||||
(make: (d: Partial<LeafNode>) => TreeNode) => {
|
(make: (d: Partial<LeafNode>) => TreeNode) => {
|
||||||
const { tree: nextTree, dropped } = reshapeToPreset(
|
const { tree: nextTree, dropped } = reshapeToPreset(
|
||||||
|
|
@ -1070,6 +1226,20 @@ export default function App() {
|
||||||
onClose={() => setMcpPanelOpen(false)}
|
onClose={() => setMcpPanelOpen(false)}
|
||||||
allowedPaneCount={allowedPaneCount}
|
allowedPaneCount={allowedPaneCount}
|
||||||
totalPaneCount={leafCount(tree)}
|
totalPaneCount={leafCount(tree)}
|
||||||
|
auditEntries={auditEntries}
|
||||||
|
onClearAudit={clearAudit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmQueue.length > 0 && (
|
||||||
|
<McpConfirm
|
||||||
|
spec={confirmQueue[0]}
|
||||||
|
onAccept={() => dismissConfirm(true)}
|
||||||
|
onReject={() => dismissConfirm(false)}
|
||||||
|
onAlwaysAllow={async () => {
|
||||||
|
await alwaysAllowTool(confirmQueue[0].tool);
|
||||||
|
dismissConfirm(true);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|
||||||
import type { McpAuditEntry } from "../ipc";
|
import type { McpAuditEntry } from "../ipc";
|
||||||
|
|
||||||
const RING_CAP = 200;
|
|
||||||
|
|
||||||
function fmtTime(tsMs: number): string {
|
function fmtTime(tsMs: number): string {
|
||||||
const d = new Date(tsMs);
|
const d = new Date(tsMs);
|
||||||
const hh = String(d.getHours()).padStart(2, "0");
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
|
@ -38,49 +34,18 @@ function rowClass(result: McpAuditEntry["result"]): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuditTabProps {
|
interface AuditTabProps {
|
||||||
/** Called when there are unread entries (tab not active). */
|
/** Audit ring, owned by App so it persists across panel open/close. */
|
||||||
onUnread?: () => void;
|
entries: McpAuditEntry[];
|
||||||
/** True when this tab is the currently visible tab — clears unread. */
|
onClear: () => void;
|
||||||
active?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuditTab({ onUnread, active }: AuditTabProps) {
|
export default function AuditTab({ entries, onClear }: 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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="audit-tab">
|
<div className="audit-tab">
|
||||||
<div className="audit-toolbar">
|
<div className="audit-toolbar">
|
||||||
{unread > 0 && !active && (
|
|
||||||
<span className="audit-unread">{unread} new</span>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
className="audit-clear"
|
className="audit-clear"
|
||||||
onClick={() => setEntries([])}
|
onClick={onClear}
|
||||||
disabled={entries.length === 0}
|
disabled={entries.length === 0}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
|
|
|
||||||
81
src/components/McpConfirm.tsx
Normal file
81
src/components/McpConfirm.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -596,3 +596,117 @@
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
line-height: 1.4;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
writeText as clipboardWriteText,
|
writeText as clipboardWriteText,
|
||||||
} from "@tauri-apps/plugin-clipboard-manager";
|
} from "@tauri-apps/plugin-clipboard-manager";
|
||||||
import type { McpStatus } from "../ipc";
|
import type { McpStatus, McpAuditEntry } from "../ipc";
|
||||||
import AuditTab from "./AuditTab";
|
import AuditTab from "./AuditTab";
|
||||||
import PolicyTab from "./PolicyTab";
|
import PolicyTab from "./PolicyTab";
|
||||||
import "./McpPanel.css";
|
import "./McpPanel.css";
|
||||||
|
|
@ -18,6 +18,9 @@ interface McpPanelProps {
|
||||||
allowedPaneCount: number;
|
allowedPaneCount: number;
|
||||||
/** Total pane count for context. */
|
/** Total pane count for context. */
|
||||||
totalPaneCount: number;
|
totalPaneCount: number;
|
||||||
|
/** Persistent audit log, owned by App so it survives panel close. */
|
||||||
|
auditEntries: McpAuditEntry[];
|
||||||
|
onClearAudit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabId = "config" | "audit" | "policy";
|
type TabId = "config" | "audit" | "policy";
|
||||||
|
|
@ -30,12 +33,17 @@ export default function McpPanel({
|
||||||
onClose,
|
onClose,
|
||||||
allowedPaneCount,
|
allowedPaneCount,
|
||||||
totalPaneCount,
|
totalPaneCount,
|
||||||
|
auditEntries,
|
||||||
|
onClearAudit,
|
||||||
}: McpPanelProps) {
|
}: McpPanelProps) {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [revealToken, setRevealToken] = useState(false);
|
const [revealToken, setRevealToken] = useState(false);
|
||||||
const [regenBusy, setRegenBusy] = useState(false);
|
const [regenBusy, setRegenBusy] = useState(false);
|
||||||
const [tab, setTab] = useState<TabId>("config");
|
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(() => {
|
useEffect(() => {
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
|
|
@ -81,7 +89,7 @@ export default function McpPanel({
|
||||||
|
|
||||||
function switchTab(id: TabId) {
|
function switchTab(id: TabId) {
|
||||||
setTab(id);
|
setTab(id);
|
||||||
if (id === "audit") setAuditUnread(false);
|
if (id === "audit") setAuditSeenCount(auditEntries.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -286,10 +294,7 @@ export default function McpPanel({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "audit" && (
|
{tab === "audit" && (
|
||||||
<AuditTab
|
<AuditTab entries={auditEntries} onClear={onClearAudit} />
|
||||||
active={tab === "audit"}
|
|
||||||
onUnread={() => setAuditUnread(true)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "policy" && <PolicyTab />}
|
{tab === "policy" && <PolicyTab />}
|
||||||
|
|
|
||||||
17
src/ipc.ts
17
src/ipc.ts
|
|
@ -173,5 +173,18 @@ export const mcpPolicyLoad = (): Promise<McpPolicy> =>
|
||||||
export const mcpPolicySave = (policy: McpPolicy): Promise<void> =>
|
export const mcpPolicySave = (policy: McpPolicy): Promise<void> =>
|
||||||
invoke("mcp_policy_save", { policy });
|
invoke("mcp_policy_save", { policy });
|
||||||
|
|
||||||
// (No JS wrapper for mcp_action_reply or events — App.tsx wires those
|
/** Subscribe to MCP action requests from the backend. Each request is a
|
||||||
// directly in the integration step.)
|
* tool call the frontend must handle (mutate state) and reply to via
|
||||||
|
* {@link mcpActionReply}. */
|
||||||
|
export const onMcpRequest = (
|
||||||
|
cb: (req: McpActionRequest) => void,
|
||||||
|
): Promise<UnlistenFn> =>
|
||||||
|
listen<McpActionRequest>("mcp://request", (e) => cb(e.payload));
|
||||||
|
|
||||||
|
/** Reply to an MCP action request. The Rust side expects an externally-
|
||||||
|
* tagged Result — `{ Ok: <value> }` on success, `{ Err: <msg> }` on
|
||||||
|
* failure or user rejection. */
|
||||||
|
export const mcpActionReply = (
|
||||||
|
requestId: string,
|
||||||
|
result: { Ok: unknown } | { Err: string },
|
||||||
|
): Promise<void> => invoke("mcp_action_reply", { requestId, result });
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue