fix: add missing tool result to AppsRenderer

This commit is contained in:
Ruben Casas 2026-02-08 00:16:35 +00:00
commit 00791446a8
2 changed files with 340 additions and 31 deletions

View file

@ -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<string | null>(null);
const [toolResult, setToolResult] = useState<CallToolResult | undefined>();
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 (
<Alert>
@ -115,6 +180,7 @@ const AppRenderer = ({
toolName={tool.name}
hostContext={hostContext}
toolInput={toolInput}
toolResult={toolResult}
sandbox={{
url: new URL(sandboxPath, window.location.origin),
}}

View file

@ -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<string, unknown>;
toolResult?: CallToolResult;
onMessage?: (
params: { role: "user"; content: { type: "text"; text: string }[] },
extra: RequestHandlerExtra,
) => Promise<McpUiMessageResult>;
};
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<McpUiMessageResult>;
}) => (
<div data-testid="mcp-ui-app-renderer">
<div data-testid="tool-name">{toolName}</div>
<button
data-testid="trigger-message"
onClick={() =>
onMessage?.(
{ role: "user", content: [{ type: "text", text: "Test message" }] },
{} as RequestHandlerExtra,
)
// Mock @mcp-ui/client to simulate bridge delivery only after app initialization
jest.mock("@mcp-ui/client", () => {
const React = jest.requireActual("react") as typeof import("react");
return {
AppRenderer: ({
toolName,
toolInput,
toolResult,
onMessage,
}: MockMcpUiRendererProps) => {
const [isInitialized, setIsInitialized] = React.useState(false);
React.useEffect(() => {
setIsInitialized(false);
}, [toolName]);
React.useEffect(() => {
if (isInitialized && toolInput) {
mockBridgeEvents.push({
type: "sendToolInput",
toolName,
payload: toolInput,
});
}
>
Trigger Message
</button>
</div>
),
}));
}, [isInitialized, toolInput, toolName]);
React.useEffect(() => {
if (isInitialized && toolResult) {
mockBridgeEvents.push({
type: "sendToolResult",
toolName,
payload: toolResult,
});
}
}, [isInitialized, toolResult, toolName]);
return (
<div data-testid="mcp-ui-app-renderer">
<div data-testid="tool-name">{toolName}</div>
<div data-testid="tool-result">
{JSON.stringify(toolResult ?? null)}
</div>
<button
data-testid="initialize-app"
onClick={() => setIsInitialized(true)}
>
Initialize App
</button>
<button
data-testid="trigger-message"
onClick={() =>
onMessage?.(
{
role: "user",
content: [{ type: "text", text: "Test message" }],
},
{} as RequestHandlerExtra,
)
}
>
Trigger Message
</button>
</div>
);
},
};
});
const createDeferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<CallToolResult>(() => {
// 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(
<AppRenderer
{...defaultProps}
mcpClient={mcpClient}
toolInput={{ monthlyBudget: 2500 }}
/>,
);
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(
<AppRenderer {...defaultProps} mcpClient={mcpClient} toolInput={{}} />,
);
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<CallToolResult>();
const secondRequest = createDeferred<CallToolResult>();
const requestMock = jest
.fn()
.mockReturnValueOnce(firstRequest.promise)
.mockReturnValueOnce(secondRequest.promise);
const mcpClient = {
...mockMcpClient,
request: requestMock,
} as unknown as Client;
const { rerender } = render(
<AppRenderer
{...defaultProps}
mcpClient={mcpClient}
toolInput={{ month: "january" }}
/>,
);
await waitFor(() => {
expect(requestMock).toHaveBeenCalledTimes(1);
});
const secondTool = {
...mockTool,
name: "budgetAllocatorApp",
};
rerender(
<AppRenderer
{...defaultProps}
mcpClient={mcpClient}
tool={secondTool}
toolInput={{ month: "february" }}
/>,
);
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");
});
});