From 00791446a8088996139162b64aa19582ed9a840a Mon Sep 17 00:00:00 2001 From: Ruben Casas Date: Sun, 8 Feb 2026 00:16:35 +0000 Subject: [PATCH] fix: add missing tool result to AppsRenderer --- client/src/components/AppRenderer.tsx | 68 +++- .../components/__tests__/AppRenderer.test.tsx | 303 ++++++++++++++++-- 2 files changed, 340 insertions(+), 31 deletions(-) diff --git a/client/src/components/AppRenderer.tsx b/client/src/components/AppRenderer.tsx index e0c259809..7ac56a16e 100644 --- a/client/src/components/AppRenderer.tsx +++ b/client/src/components/AppRenderer.tsx @@ -1,8 +1,10 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { Tool, ContentBlock, + CallToolResult, + CallToolResultSchema, ServerNotification, LoggingMessageNotificationParams, } from "@modelcontextprotocol/sdk/types.js"; @@ -35,6 +37,8 @@ const AppRenderer = ({ onNotification, }: AppRendererProps) => { const [error, setError] = useState(null); + const [toolResult, setToolResult] = useState(); + const latestRunIdRef = useRef(0); const { toast } = useToast(); const hostContext: McpUiHostContext = useMemo( @@ -85,6 +89,67 @@ const AppRenderer = ({ } }; + useEffect(() => { + if (!mcpClient) { + setToolResult(undefined); + return; + } + + const runId = ++latestRunIdRef.current; + const abortController = new AbortController(); + setToolResult(undefined); + + const runTool = async () => { + try { + const result = await mcpClient.request( + { + method: "tools/call", + params: { + name: tool.name, + arguments: toolInput ?? {}, + }, + }, + CallToolResultSchema, + { signal: abortController.signal }, + ); + + if ( + abortController.signal.aborted || + runId !== latestRunIdRef.current + ) { + return; + } + + setToolResult(result); + } catch (runError) { + if ( + abortController.signal.aborted || + runId !== latestRunIdRef.current + ) { + return; + } + + const message = + runError instanceof Error ? runError.message : String(runError); + setToolResult({ + content: [ + { + type: "text", + text: message, + }, + ], + isError: true, + }); + } + }; + + void runTool(); + + return () => { + abortController.abort(); + }; + }, [mcpClient, tool.name, toolInput]); + if (!mcpClient) { return ( @@ -115,6 +180,7 @@ const AppRenderer = ({ toolName={tool.name} hostContext={hostContext} toolInput={toolInput} + toolResult={toolResult} sandbox={{ url: new URL(sandboxPath, window.location.origin), }} diff --git a/client/src/components/__tests__/AppRenderer.test.tsx b/client/src/components/__tests__/AppRenderer.test.tsx index beb57e1ab..723e4f98c 100644 --- a/client/src/components/__tests__/AppRenderer.test.tsx +++ b/client/src/components/__tests__/AppRenderer.test.tsx @@ -1,12 +1,30 @@ -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, jest, beforeEach } from "@jest/globals"; import AppRenderer from "../AppRenderer"; -import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { RequestHandlerExtra } from "@mcp-ui/client"; import { McpUiMessageResult } from "@modelcontextprotocol/ext-apps"; +type BridgeEvent = { + type: "sendToolInput" | "sendToolResult"; + toolName: string; + payload: unknown; +}; + +type MockMcpUiRendererProps = { + toolName: string; + toolInput?: Record; + toolResult?: CallToolResult; + onMessage?: ( + params: { role: "user"; content: { type: "text"; text: string }[] }, + extra: RequestHandlerExtra, + ) => Promise; +}; + +const mockBridgeEvents: BridgeEvent[] = []; + // Mock the ext-apps module jest.mock("@modelcontextprotocol/ext-apps/app-bridge", () => ({ getToolUiResourceUri: (tool: Tool) => { @@ -24,34 +42,84 @@ jest.mock("@/lib/hooks/useToast", () => ({ }), })); -// Mock @mcp-ui/client -jest.mock("@mcp-ui/client", () => ({ - AppRenderer: ({ - toolName, - onMessage, - }: { - toolName: string; - onMessage?: ( - params: { role: "user"; content: { type: "text"; text: string }[] }, - extra: RequestHandlerExtra, - ) => Promise; - }) => ( -
-
{toolName}
- -
- ), -})); + }, [isInitialized, toolInput, toolName]); + + React.useEffect(() => { + if (isInitialized && toolResult) { + mockBridgeEvents.push({ + type: "sendToolResult", + toolName, + payload: toolResult, + }); + } + }, [isInitialized, toolResult, toolName]); + + return ( +
+
{toolName}
+
+ {JSON.stringify(toolResult ?? null)} +
+ + +
+ ); + }, + }; +}); + +const createDeferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; describe("AppRenderer", () => { const mockTool: Tool = { @@ -69,7 +137,12 @@ describe("AppRenderer", () => { } as Tool & { _meta?: { ui?: { resourceUri?: string } } }; const mockMcpClient = { - request: jest.fn(), + request: jest.fn( + () => + new Promise(() => { + // Intentionally unresolved for baseline rendering tests. + }), + ), getServerCapabilities: jest.fn().mockReturnValue({}), setNotificationHandler: jest.fn(), } as unknown as Client; @@ -82,6 +155,7 @@ describe("AppRenderer", () => { beforeEach(() => { jest.clearAllMocks(); + mockBridgeEvents.length = 0; }); it("should display waiting state when mcpClient is null", () => { @@ -112,4 +186,173 @@ describe("AppRenderer", () => { description: "Test message", }); }); + + it("should call tools/call and send tool input/result after app initialization", async () => { + const mockResult: CallToolResult = { + content: [{ type: "text", text: "Budget initialized" }], + }; + const requestMock = jest.fn().mockResolvedValue(mockResult); + const mcpClient = { + ...mockMcpClient, + request: requestMock, + } as unknown as Client; + + render( + , + ); + + await waitFor(() => { + expect(requestMock).toHaveBeenCalledWith( + { + method: "tools/call", + params: { + name: "testApp", + arguments: { monthlyBudget: 2500 }, + }, + }, + expect.any(Object), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + expect(mockBridgeEvents).toHaveLength(0); + + fireEvent.click(screen.getByTestId("initialize-app")); + + await waitFor(() => { + const toolEvents = mockBridgeEvents.filter( + (event) => event.toolName === "testApp", + ); + expect(toolEvents.map((event) => event.type)).toEqual([ + "sendToolInput", + "sendToolResult", + ]); + }); + + const resultEvent = mockBridgeEvents.find( + (event) => + event.toolName === "testApp" && event.type === "sendToolResult", + ); + expect(resultEvent?.payload).toEqual(mockResult); + }); + + it("should send an app-consumable error result when tools/call fails", async () => { + const requestMock = jest + .fn() + .mockRejectedValue(new Error("tool execution failed")); + const mcpClient = { + ...mockMcpClient, + request: requestMock, + } as unknown as Client; + + render( + , + ); + + await waitFor(() => { + expect(requestMock).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(screen.getByTestId("initialize-app")); + + await waitFor(() => { + const resultEvent = mockBridgeEvents.find( + (event) => + event.toolName === "testApp" && event.type === "sendToolResult", + ); + + expect(resultEvent?.payload).toEqual({ + content: [{ type: "text", text: "tool execution failed" }], + isError: true, + }); + }); + }); + + it("should ignore stale tool results after switching app tools", async () => { + const firstResult: CallToolResult = { + content: [{ type: "text", text: "stale result" }], + }; + const secondResult: CallToolResult = { + content: [{ type: "text", text: "fresh result" }], + }; + + const firstRequest = createDeferred(); + const secondRequest = createDeferred(); + const requestMock = jest + .fn() + .mockReturnValueOnce(firstRequest.promise) + .mockReturnValueOnce(secondRequest.promise); + + const mcpClient = { + ...mockMcpClient, + request: requestMock, + } as unknown as Client; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(requestMock).toHaveBeenCalledTimes(1); + }); + + const secondTool = { + ...mockTool, + name: "budgetAllocatorApp", + }; + + rerender( + , + ); + + await waitFor(() => { + expect(requestMock).toHaveBeenCalledTimes(2); + }); + + secondRequest.resolve(secondResult); + await waitFor(() => { + expect(screen.getByTestId("tool-result")).toHaveTextContent( + "fresh result", + ); + }); + + fireEvent.click(screen.getByTestId("initialize-app")); + + await waitFor(() => { + const freshEvents = mockBridgeEvents.filter( + (event) => + event.toolName === "budgetAllocatorApp" && + event.type === "sendToolResult", + ); + expect(freshEvents).toHaveLength(1); + expect(freshEvents[0].payload).toEqual(secondResult); + }); + + firstRequest.resolve(firstResult); + await Promise.resolve(); + await Promise.resolve(); + + const secondToolResultEvents = mockBridgeEvents.filter( + (event) => + event.toolName === "budgetAllocatorApp" && + event.type === "sendToolResult", + ); + + expect(secondToolResultEvents).toHaveLength(1); + expect(secondToolResultEvents[0].payload).toEqual(secondResult); + expect(screen.getByTestId("tool-result")).toHaveTextContent("fresh result"); + }); });