fix: add missing tool result to AppsRenderer
This commit is contained in:
parent
0c0e15dbd8
commit
00791446a8
2 changed files with 340 additions and 31 deletions
|
|
@ -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),
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue