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,
|
||||
mcpRegenerateToken,
|
||||
mcpUpdateState,
|
||||
onMcpRequest,
|
||||
mcpActionReply,
|
||||
mcpPolicyLoad,
|
||||
mcpPolicySave,
|
||||
writeToPane,
|
||||
killPane,
|
||||
type PaneId,
|
||||
|
|
@ -20,7 +24,10 @@ import {
|
|||
type McpMirror,
|
||||
type McpMirroredLeaf,
|
||||
type McpMirroredHost,
|
||||
type McpActionRequest,
|
||||
type McpAuditEntry,
|
||||
} from "./ipc";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import {
|
||||
type TreeNode,
|
||||
type NodeId,
|
||||
|
|
@ -64,6 +71,7 @@ import Palette from "./components/Palette";
|
|||
import HostManager from "./components/HostManager";
|
||||
import Help from "./components/Help";
|
||||
import McpPanel from "./components/McpPanel";
|
||||
import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm";
|
||||
import "./App.css";
|
||||
import "./lib/layout/Gutter.css";
|
||||
|
||||
|
|
@ -728,6 +736,154 @@ export default function App() {
|
|||
);
|
||||
}, [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(
|
||||
(make: (d: Partial<LeafNode>) => TreeNode) => {
|
||||
const { tree: nextTree, dropped } = reshapeToPreset(
|
||||
|
|
@ -1070,6 +1226,20 @@ export default function App() {
|
|||
onClose={() => setMcpPanelOpen(false)}
|
||||
allowedPaneCount={allowedPaneCount}
|
||||
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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue