From bfd96fe900d3ddb0b234c8bfe74089cb6843f016 Mon Sep 17 00:00:00 2001 From: Olabode Olaoke Date: Tue, 1 Sep 2026 21:48:58 -0600 Subject: [PATCH 1/3] Require confirmation for MCP app messages Prevent MCP apps from silently spending user chat authority by requiring a host-owned confirmation bound to the current app request. Co-authored-by: Olabode Olaoke Signed-off-by: Olabode Olaoke --- src/features/chat/ui/McpAppView.tsx | 194 ++++++++++++++++- .../chat/ui/__tests__/McpAppView.test.tsx | 204 ++++++++++++++++++ src/shared/i18n/locales/en/chat.json | 4 + src/shared/i18n/locales/es/chat.json | 4 + 4 files changed, 397 insertions(+), 9 deletions(-) diff --git a/src/features/chat/ui/McpAppView.tsx b/src/features/chat/ui/McpAppView.tsx index 7ec8dcb7d..7ae5c150b 100644 --- a/src/features/chat/ui/McpAppView.tsx +++ b/src/features/chat/ui/McpAppView.tsx @@ -6,6 +6,15 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import packageJson from "../../../../package.json"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; import { getClientForSession, getWireSessionId, @@ -64,6 +73,16 @@ type ReadResourceResult = Awaited< type HostContextToolInfo = NonNullable; type HostContextTool = HostContextToolInfo["tool"]; +interface PendingAppMessage { + nonce: number; + sessionId: string; + toolCallId: string; + extensionName: string; + toolName: string; + text: string; + resolve: (result: { isError?: boolean }) => void; +} + function buildToolResult( toolResponse: ToolResponseContent | undefined, ): CallToolResult | undefined { @@ -134,9 +153,14 @@ export function McpAppView({ >(); const [containerWidth, setContainerWidth] = useState(null); const [isIframeSizingPending, setIsIframeSizingPending] = useState(false); + const [pendingAppMessage, setPendingAppMessage] = + useState(null); const autoScrollTimersRef = useRef([]); const iframeSizingRafRef = useRef([]); const mcpRequestSourceCounterRef = useRef(0); + const appMessageNonceRef = useRef(0); + const appMessageInFlightRef = useRef(false); + const pendingAppMessageRef = useRef(null); const rootRef = useRef(null); const { enabled: rowStateEnabled, @@ -163,11 +187,26 @@ export function McpAppView({ const currentToolResult = initialToolResult; useTranscriptOpenOverlayProtection({ - open: pendingOpenLinkUrl !== null, + open: pendingOpenLinkUrl !== null || pendingAppMessage !== null, overlayKind: "dialog", - overlayId: "mcp-link-safety", + overlayId: pendingAppMessage + ? "mcp-message-confirmation" + : "mcp-link-safety", }); + const settlePendingAppMessage = useCallback( + (request: PendingAppMessage, result: { isError?: boolean }) => { + if (pendingAppMessageRef.current?.nonce !== request.nonce) { + return false; + } + pendingAppMessageRef.current = null; + setPendingAppMessage(null); + request.resolve(result); + return true; + }, + [], + ); + const requestAutoScroll = useCallback(() => { if (!onAutoScrollRequest) { return; @@ -210,6 +249,35 @@ export function McpAppView({ [], ); + useEffect(() => { + const pending = pendingAppMessageRef.current; + if ( + pending && + (pending.sessionId !== payload.sessionId || + pending.toolCallId !== payload.toolCallId || + pending.extensionName !== payload.tool.extensionName || + pending.toolName !== payload.tool.name) + ) { + settlePendingAppMessage(pending, { isError: true }); + } + }, [ + payload.sessionId, + payload.toolCallId, + payload.tool.extensionName, + payload.tool.name, + settlePendingAppMessage, + ]); + + useEffect( + () => () => { + const pending = pendingAppMessageRef.current; + if (pending) { + settlePendingAppMessage(pending, { isError: true }); + } + }, + [settlePendingAppMessage], + ); + useEffect(() => { const root = rootRef.current; if (!root) { @@ -341,34 +409,107 @@ export function McpAppView({ const handleMessage = useCallback( async ({ role, content }: MessageParams) => { - if (role !== "user" || !onSendMessage) { + if ( + role !== "user" || + !Array.isArray(content) || + !onSendMessage || + pendingAppMessageRef.current || + appMessageInFlightRef.current + ) { return { isError: true }; } const text = content .filter((block): block is { type: "text"; text: string } => { return ( + typeof block === "object" && + block !== null && block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0 ); }) - .map((block) => block.text.trim()) + .map((block) => block.text) .join("\n\n"); if (!text) { return { isError: true }; } - setMcpActivity("recent-message", true, { - sourceId: "mcp-message", + return new Promise<{ isError?: boolean }>((resolve) => { + const request: PendingAppMessage = { + nonce: ++appMessageNonceRef.current, + sessionId: payload.sessionId, + toolCallId: payload.toolCallId, + extensionName: payload.tool.extensionName, + toolName: payload.tool.name, + text, + resolve, + }; + pendingAppMessageRef.current = request; + setPendingAppMessage(request); }); - const accepted = await onSendMessage(text); - return accepted === false ? { isError: true } : {}; }, - [onSendMessage, setMcpActivity], + [ + onSendMessage, + payload.sessionId, + payload.tool.extensionName, + payload.tool.name, + payload.toolCallId, + ], ); + const rejectPendingAppMessage = useCallback(() => { + const pending = pendingAppMessageRef.current; + if (pending) { + settlePendingAppMessage(pending, { isError: true }); + } + }, [settlePendingAppMessage]); + + const confirmPendingAppMessage = useCallback(async () => { + const pending = pendingAppMessageRef.current; + if ( + !pending || + !onSendMessage || + pending.sessionId !== payload.sessionId || + pending.toolCallId !== payload.toolCallId || + pending.extensionName !== payload.tool.extensionName || + pending.toolName !== payload.tool.name + ) { + if (pending) { + settlePendingAppMessage(pending, { isError: true }); + } + return; + } + + // Clear authority before awaiting delivery so double-clicks and replayed + // app requests cannot spend the same confirmation twice. + pendingAppMessageRef.current = null; + appMessageInFlightRef.current = true; + setPendingAppMessage(null); + try { + const accepted = await onSendMessage(pending.text); + pending.resolve(accepted === false ? { isError: true } : {}); + if (accepted !== false) { + setMcpActivity("recent-message", true, { + sourceId: `mcp-message:${pending.nonce}`, + }); + } + } catch { + pending.resolve({ isError: true }); + } finally { + appMessageInFlightRef.current = false; + } + }, [ + onSendMessage, + payload.sessionId, + payload.tool.extensionName, + payload.tool.name, + payload.toolCallId, + setMcpActivity, + settlePendingAppMessage, + ]); + const handleCallTool = useCallback( async ({ name, @@ -543,6 +684,41 @@ export function McpAppView({ )} )} + { + if (!open) { + rejectPendingAppMessage(); + } + }} + > + + + {t("message.mcpAppMessageConfirmTitle")} + + {t("message.mcpAppMessageConfirmDescription", { + extension: pendingAppMessage?.extensionName, + tool: pendingAppMessage?.toolName, + })} + + +
+ {pendingAppMessage?.text} +
+ + + + +
+
{ }); }); + it("requires one host confirmation before sending exact app-authored text", async () => { + let resolveSend: (accepted: boolean) => void = () => {}; + const onSendMessage = vi.fn( + () => + new Promise((resolve) => { + resolveSend = resolve; + }), + ); + render( + , + ); + await waitFor(() => { + expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument(); + }); + + let resultPromise: Promise | undefined; + await act(async () => { + resultPromise = getLatestAppRendererProps().onMessage?.( + { + role: "user", + content: [{ type: "text", text: " exact app text " }], + }, + {} as RequestHandlerExtra, + ); + }); + + expect(onSendMessage).not.toHaveBeenCalled(); + expect( + screen.getByText( + (_, element) => element?.textContent === " exact app text ", + ), + ).toBeInTheDocument(); + expect( + screen.getByText(/mcpappbench_local_.*inspect-messaging/), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => { + expect(onSendMessage).toHaveBeenCalledTimes(1); + }); + expect(onSendMessage).toHaveBeenCalledWith(" exact app text "); + expect( + screen.queryByRole("button", { name: "Send message" }), + ).not.toBeInTheDocument(); + + const replayed = getLatestAppRendererProps().onMessage?.( + { role: "user", content: [{ type: "text", text: "replayed" }] }, + {} as RequestHandlerExtra, + ); + await expect(replayed).resolves.toEqual({ isError: true }); + expect(onSendMessage).toHaveBeenCalledTimes(1); + + resolveSend(true); + await expect(resultPromise).resolves.toEqual({}); + }); + + it("rejects a pending app message without sending it", async () => { + const onSendMessage = vi.fn(() => true); + render( + , + ); + await waitFor(() => { + expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument(); + }); + + let resultPromise: Promise | undefined; + await act(async () => { + resultPromise = getLatestAppRendererProps().onMessage?.( + { role: "user", content: [{ type: "text", text: "do not send" }] }, + {} as RequestHandlerExtra, + ); + }); + fireEvent.click(await screen.findByRole("button", { name: "Cancel" })); + + expect(onSendMessage).not.toHaveBeenCalled(); + await expect(resultPromise).resolves.toEqual({ isError: true }); + }); + + it("rejects concurrent app messages instead of cross-approving them", async () => { + const onSendMessage = vi.fn(() => true); + render( + , + ); + await waitFor(() => { + expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument(); + }); + + const onMessage = getLatestAppRendererProps().onMessage; + let first: Promise | undefined; + let second: Promise | undefined; + await act(async () => { + first = onMessage?.( + { role: "user", content: [{ type: "text", text: "first" }] }, + {} as RequestHandlerExtra, + ); + second = onMessage?.( + { role: "user", content: [{ type: "text", text: "second" }] }, + {} as RequestHandlerExtra, + ); + }); + + await expect(second).resolves.toEqual({ isError: true }); + fireEvent.click( + await screen.findByRole("button", { name: "Send message" }), + ); + await expect(first).resolves.toEqual({}); + expect(onSendMessage).toHaveBeenCalledTimes(1); + expect(onSendMessage).toHaveBeenCalledWith("first"); + }); + + it("invalidates pending confirmation when its session or app identity changes", async () => { + const onSendMessage = vi.fn(() => true); + const { rerender } = render( + , + ); + await waitFor(() => { + expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument(); + }); + + let resultPromise: Promise | undefined; + await act(async () => { + resultPromise = getLatestAppRendererProps().onMessage?.( + { role: "user", content: [{ type: "text", text: "stale" }] }, + {} as RequestHandlerExtra, + ); + }); + rerender( + , + ); + + await expect(resultPromise).resolves.toEqual({ isError: true }); + expect(onSendMessage).not.toHaveBeenCalled(); + expect( + screen.queryByRole("button", { name: "Send message" }), + ).not.toBeInTheDocument(); + }); + + it("re-checks send admission at approval and rejects malformed messages", async () => { + const onSendMessage = vi.fn(() => false); + render( + , + ); + await waitFor(() => { + expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument(); + }); + + const onMessage = getLatestAppRendererProps().onMessage; + await expect( + onMessage?.( + { + role: "assistant" as "user", + content: [{ type: "text", text: "forged" }], + }, + {} as RequestHandlerExtra, + ), + ).resolves.toEqual({ isError: true }); + await expect( + onMessage?.( + { role: "user", content: [{ type: "text", text: " " }] }, + {} as RequestHandlerExtra, + ), + ).resolves.toEqual({ isError: true }); + + let blocked: Promise | undefined; + await act(async () => { + blocked = onMessage?.( + { role: "user", content: [{ type: "text", text: "blocked now" }] }, + {} as RequestHandlerExtra, + ); + }); + fireEvent.click( + await screen.findByRole("button", { name: "Send message" }), + ); + + await expect(blocked).resolves.toEqual({ isError: true }); + expect(onSendMessage).toHaveBeenCalledTimes(1); + expect(onSendMessage).toHaveBeenCalledWith("blocked now"); + }); + it("keeps the original toolResult after nested app tool calls resolve", async () => { const nestedToolResult = { content: [{ type: "text", text: "2026-04-22T18:29:06.433Z" }], diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index 103dc0536..961555d3f 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -401,6 +401,10 @@ "mcpApp": "MCP App", "mcpAppLoading": "Loading MCP App…", "mcpAppRenderError": "Unable to render MCP App inline.", + "mcpAppMessageConfirmTitle": "Send this app message?", + "mcpAppMessageConfirmDescription": "{{extension}} / {{tool}} wants to send this message as you. Review the exact text before sending.", + "mcpAppMessageCancel": "Cancel", + "mcpAppMessageSend": "Send message", "redactedThinking": "(thinking redacted)", "responseFeedbackGood": "Good response", "responseFeedbackBad": "Bad response", diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 720104f25..2652eb718 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -400,6 +400,10 @@ "mcpApp": "MCP App", "mcpAppLoading": "Cargando MCP App…", "mcpAppRenderError": "No se pudo renderizar MCP App en línea.", + "mcpAppMessageConfirmTitle": "¿Enviar este mensaje de la aplicación?", + "mcpAppMessageConfirmDescription": "{{extension}} / {{tool}} quiere enviar este mensaje como tú. Revisa el texto exacto antes de enviarlo.", + "mcpAppMessageCancel": "Cancelar", + "mcpAppMessageSend": "Enviar mensaje", "redactedThinking": "(pensamiento redactado)", "responseFeedbackGood": "Buena respuesta", "responseFeedbackBad": "Mala respuesta", From be790138952c8c24f515c9fec25d5b9d8dc2ebaf Mon Sep 17 00:00:00 2001 From: Olabode Olaoke Date: Wed, 2 Sep 2026 14:29:42 -0600 Subject: [PATCH 2/3] Prevent overlapping MCP app dialogs Keep link and message confirmations mutually exclusive so focus trapping and transcript overlay tracking remain accurate. Co-authored-by: Olabode Olaoke Signed-off-by: Olabode Olaoke --- src/features/chat/ui/McpAppView.tsx | 14 +++- .../chat/ui/__tests__/McpAppView.test.tsx | 72 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/src/features/chat/ui/McpAppView.tsx b/src/features/chat/ui/McpAppView.tsx index 7ae5c150b..59f278bc4 100644 --- a/src/features/chat/ui/McpAppView.tsx +++ b/src/features/chat/ui/McpAppView.tsx @@ -407,12 +407,23 @@ export function McpAppView({ [containerWidth, inlineHeight, payload, resolvedTheme], ); + const handleExclusiveOpenLink = useCallback( + async (...args: Parameters) => { + if (pendingAppMessageRef.current) { + return { isError: true }; + } + return handleOpenLink(...args); + }, + [handleOpenLink], + ); + const handleMessage = useCallback( async ({ role, content }: MessageParams) => { if ( role !== "user" || !Array.isArray(content) || !onSendMessage || + pendingOpenLinkUrl !== null || pendingAppMessageRef.current || appMessageInFlightRef.current ) { @@ -452,6 +463,7 @@ export function McpAppView({ }, [ onSendMessage, + pendingOpenLinkUrl, payload.sessionId, payload.tool.extensionName, payload.tool.name, @@ -660,7 +672,7 @@ export function McpAppView({ toolInput={currentToolInput} toolResult={currentToolResult} hostContext={hostContext} - onOpenLink={handleOpenLink} + onOpenLink={handleExclusiveOpenLink} onMessage={handleMessage} onCallTool={handleCallTool} onReadResource={handleReadResource} diff --git a/src/features/chat/ui/__tests__/McpAppView.test.tsx b/src/features/chat/ui/__tests__/McpAppView.test.tsx index 7df43cc11..a84ac347a 100644 --- a/src/features/chat/ui/__tests__/McpAppView.test.tsx +++ b/src/features/chat/ui/__tests__/McpAppView.test.tsx @@ -283,6 +283,78 @@ describe("McpAppView nested tool calls", () => { expect(onSendMessage).toHaveBeenCalledWith("first"); }); + it("rejects an app message while link confirmation is pending", async () => { + const onSendMessage = vi.fn(() => true); + render( + , + ); + await waitFor(() => { + expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument(); + }); + + let linkPromise: Promise | undefined; + await act(async () => { + linkPromise = getLatestAppRendererProps().onOpenLink?.( + { url: "https://example.com" }, + {} as RequestHandlerExtra, + ); + }); + await screen.findByText("https://example.com/"); + + const result = await getLatestAppRendererProps().onMessage?.( + { role: "user", content: [{ type: "text", text: "blocked" }] }, + {} as RequestHandlerExtra, + ); + + expect(result).toEqual({ isError: true }); + expect(onSendMessage).not.toHaveBeenCalled(); + expect( + screen.queryByRole("button", { name: "Send message" }), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Close" })); + await expect(linkPromise).resolves.toEqual({ isError: true }); + }); + + it("rejects an open link while app message confirmation is pending", async () => { + const onSendMessage = vi.fn(() => true); + render( + , + ); + await waitFor(() => { + expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument(); + }); + + let messagePromise: Promise | undefined; + await act(async () => { + messagePromise = getLatestAppRendererProps().onMessage?.( + { role: "user", content: [{ type: "text", text: "pending" }] }, + {} as RequestHandlerExtra, + ); + }); + await screen.findByRole("button", { name: "Send message" }); + + const result = await getLatestAppRendererProps().onOpenLink?.( + { url: "https://example.com" }, + {} as RequestHandlerExtra, + ); + + expect(result).toEqual({ isError: true }); + expect(screen.queryByText("https://example.com/")).not.toBeInTheDocument(); + expect(openUrl).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + await expect(messagePromise).resolves.toEqual({ isError: true }); + }); + it("invalidates pending confirmation when its session or app identity changes", async () => { const onSendMessage = vi.fn(() => true); const { rerender } = render( From 6a9d96633b65eaf32804a9c276d9007951869cc9 Mon Sep 17 00:00:00 2001 From: Sleek <93c2629a5f1f93118df6264f931480b8f7b585d5f425aa459e48efd6e883ee14@buzz.block.builderlab.xyz> Date: Wed, 2 Sep 2026 15:38:53 -0600 Subject: [PATCH 3/3] Reuse MCP message activity signal Refresh one expiring row-protection signal after each approved app message instead of accumulating a signal per confirmation. Signed-off-by: Sleek <93c2629a5f1f93118df6264f931480b8f7b585d5f425aa459e48efd6e883ee14@buzz.block.builderlab.xyz> --- src/features/chat/ui/McpAppView.tsx | 2 +- .../chat/ui/__tests__/McpAppView.test.tsx | 44 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/features/chat/ui/McpAppView.tsx b/src/features/chat/ui/McpAppView.tsx index 59f278bc4..a6aaa2805 100644 --- a/src/features/chat/ui/McpAppView.tsx +++ b/src/features/chat/ui/McpAppView.tsx @@ -504,7 +504,7 @@ export function McpAppView({ pending.resolve(accepted === false ? { isError: true } : {}); if (accepted !== false) { setMcpActivity("recent-message", true, { - sourceId: `mcp-message:${pending.nonce}`, + sourceId: "mcp-message", }); } } catch { diff --git a/src/features/chat/ui/__tests__/McpAppView.test.tsx b/src/features/chat/ui/__tests__/McpAppView.test.tsx index a84ac347a..ea648aa2a 100644 --- a/src/features/chat/ui/__tests__/McpAppView.test.tsx +++ b/src/features/chat/ui/__tests__/McpAppView.test.tsx @@ -221,6 +221,50 @@ describe("McpAppView nested tool calls", () => { await expect(resultPromise).resolves.toEqual({}); }); + it("refreshes one recent-message protection signal across approvals", async () => { + const registry = createTranscriptRowStateRegistry(); + const onSendMessage = vi.fn(() => true); + render( + + + , + ); + await waitFor(() => { + expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument(); + }); + + for (const text of ["first", "second"]) { + let resultPromise: Promise<{ isError?: boolean } | undefined> | undefined; + await act(async () => { + resultPromise = getLatestAppRendererProps().onMessage?.( + { role: "user", content: [{ type: "text", text }] }, + {} as RequestHandlerExtra, + ); + }); + const sendButton = await screen.findByRole("button", { + name: "Send message", + }); + await act(async () => { + fireEvent.click(sendButton); + await resultPromise; + }); + await expect(resultPromise).resolves.toEqual({}); + } + + expect(onSendMessage).toHaveBeenCalledTimes(2); + expect(registry.cleanupSession("virtual-session")).toMatchObject({ + removedProtectionSignalCount: 1, + }); + }); + it("rejects a pending app message without sending it", async () => { const onSendMessage = vi.fn(() => true); render(