Merge pull request #1075 from infoxicator/fix/apps-tool-result

fix: add missing tool result to AppsRenderer
This commit is contained in:
Cliff Hall 2026-02-16 17:44:43 -05:00 • committed by GitHub
commit f1b42654dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1085 additions and 117 deletions

View file

@ -30,6 +30,7 @@ import {
hasValidMetaPrefix,
isReservedMetaKey,
} from "@/utils/metaUtils";
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types";
import { OAuthStateMachine } from "./lib/oauth-state-machine";
import { cacheToolOutputSchemas } from "./utils/schemaUtils";
@ -101,6 +102,27 @@ import MetadataTab from "./components/MetadataTab";
const CONFIG_LOCAL_STORAGE_KEY = "inspectorConfig_v1";
type PrefilledAppsToolCall = {
id: number;
toolName: string;
params: Record<string, unknown>;
result: CompatibilityCallToolResult;
};
const hasAppResourceUri = (tool: Tool): boolean => {
return Boolean(getToolUiResourceUri(tool));
};
const cloneToolParams = (
source: Record<string, unknown>,
): Record<string, unknown> => {
try {
return structuredClone(source);
} catch {
return { ...source };
}
};
const filterReservedMetadata = (
metadata: Record<string, string>,
): Record<string, string> => {
@ -137,6 +159,8 @@ const App = () => {
const [tasks, setTasks] = useState<Task[]>([]);
const [toolResult, setToolResult] =
useState<CompatibilityCallToolResult | null>(null);
const [prefilledAppsToolCall, setPrefilledAppsToolCall] =
useState<PrefilledAppsToolCall | null>(null);
const [errors, setErrors] = useState<Record<string, string | null>>({
resources: null,
prompts: null,
@ -291,6 +315,7 @@ const App = () => {
const [nextToolCursor, setNextToolCursor] = useState<string | undefined>();
const [nextTaskCursor, setNextTaskCursor] = useState<string | undefined>();
const progressTokenRef = useRef(0);
const prefilledAppsToolCallIdRef = useRef(0);
const [activeTab, setActiveTab] = useState<string>(() => {
const hash = window.location.hash.slice(1);
@ -977,7 +1002,7 @@ const App = () => {
params: Record<string, unknown>,
toolMetadata?: Record<string, unknown>,
runAsTask?: boolean,
) => {
): Promise<CompatibilityCallToolResult> => {
lastToolCallOriginTabRef.current = currentTabRef.current;
try {
@ -1045,7 +1070,7 @@ const App = () => {
"_meta" in (response as Record<string, unknown>)
? ((response as { _meta?: Record<string, unknown> })._meta ?? {})
: undefined;
setToolResult({
let latestToolResult: CompatibilityCallToolResult = {
content: [
{
type: "text",
@ -1056,7 +1081,8 @@ const App = () => {
...(initialResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
};
setToolResult(latestToolResult);
// Polling loop
let taskCompleted = false;
@ -1093,12 +1119,13 @@ const App = () => {
CompatibilityCallToolResultSchema,
);
console.log(`Result received for task ${taskId}:`, result);
setToolResult(result as CompatibilityCallToolResult);
latestToolResult = result as CompatibilityCallToolResult;
setToolResult(latestToolResult);
// Refresh tasks list to show completed state
void listTasks();
} else {
setToolResult({
latestToolResult = {
content: [
{
type: "text",
@ -1106,7 +1133,8 @@ const App = () => {
},
],
isError: true,
});
};
setToolResult(latestToolResult);
// Refresh tasks list to show failed/cancelled state
void listTasks();
}
@ -1120,7 +1148,7 @@ const App = () => {
? ((response as { _meta?: Record<string, unknown> })._meta ??
{})
: undefined;
setToolResult({
latestToolResult = {
content: [
{
type: "text",
@ -1131,13 +1159,14 @@ const App = () => {
...(pollingResponseMeta || {}),
"io.modelcontextprotocol/related-task": { taskId },
},
} as CompatibilityCallToolResult);
};
setToolResult(latestToolResult);
// Refresh tasks list to show progress
void listTasks();
}
} catch (pollingError) {
console.error("Error polling task status:", pollingError);
setToolResult({
latestToolResult = {
content: [
{
type: "text",
@ -1145,16 +1174,22 @@ const App = () => {
},
],
isError: true,
});
};
setToolResult(latestToolResult);
taskCompleted = true;
}
}
setIsPollingTask(false);
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
return latestToolResult;
} else {
setToolResult(response as CompatibilityCallToolResult);
const directResult = response as CompatibilityCallToolResult;
setToolResult(directResult);
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
return directResult;
}
// Clear any validation errors since tool execution completed
setErrors((prev) => ({ ...prev, tools: null }));
} catch (e) {
const toolResult: CompatibilityCallToolResult = {
content: [
@ -1168,6 +1203,7 @@ const App = () => {
setToolResult(toolResult);
// Clear validation errors - tool execution errors are shown in ToolResults
setErrors((prev) => ({ ...prev, tools: null }));
return toolResult;
}
};
@ -1508,7 +1544,26 @@ const App = () => {
) => {
clearError("tools");
setToolResult(null);
await callTool(name, params, metadata, runAsTask);
const result = await callTool(
name,
params,
metadata,
runAsTask,
);
const calledTool = tools.find(
(tool) => tool.name === name,
);
if (calledTool && hasAppResourceUri(calledTool)) {
setPrefilledAppsToolCall({
id: ++prefilledAppsToolCallIdRef.current,
toolName: name,
params: cloneToolParams(params),
result,
});
} else {
setPrefilledAppsToolCall(null);
}
return result;
}}
selectedTool={selectedTool}
setSelectedTool={(tool) => {
@ -1552,6 +1607,22 @@ const App = () => {
clearError("tools");
listTools();
}}
callTool={async (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => {
clearError("tools");
setToolResult(null);
return callTool(name, params, metadata, runAsTask);
}}
prefilledToolCall={prefilledAppsToolCall}
onPrefilledToolCallConsumed={(callId) => {
setPrefilledAppsToolCall((prev) =>
prev?.id === callId ? null : prev,
);
}}
error={errors.tools}
mcpClient={mcpClient}
onNotification={(notification) => {

View file

@ -0,0 +1,284 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import App from "../App";
import { useConnection } from "../lib/hooks/useConnection";
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
type ToolListEntry = {
name: string;
inputSchema: {
type: "object";
properties: Record<string, unknown>;
};
_meta: Record<string, unknown>;
};
type AppsTabProps = {
tools: ToolListEntry[];
prefilledToolCall?: {
id: number;
toolName: string;
params: Record<string, unknown>;
result: {
content: Array<{ type: string; text: string }>;
};
} | null;
onPrefilledToolCallConsumed?: (callId: number) => void;
};
// Mock auth dependencies first
jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({
auth: jest.fn(),
}));
jest.mock("../lib/oauth-state-machine", () => ({
OAuthStateMachine: jest.fn(),
}));
jest.mock("../lib/auth", () => ({
InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({
tokens: jest.fn().mockResolvedValue(null),
clear: jest.fn(),
})),
DebugInspectorOAuthClientProvider: jest.fn(),
}));
jest.mock("../utils/configUtils", () => ({
...jest.requireActual("../utils/configUtils"),
getMCPProxyAddress: jest.fn(() => "http://localhost:6277"),
getMCPProxyAuthToken: jest.fn(() => ({
token: "",
header: "X-MCP-Proxy-Auth",
})),
getInitialTransportType: jest.fn(() => "stdio"),
getInitialSseUrl: jest.fn(() => "http://localhost:3001/sse"),
getInitialCommand: jest.fn(() => "mcp-server-everything"),
getInitialArgs: jest.fn(() => ""),
initializeInspectorConfig: jest.fn(() => ({})),
saveInspectorConfig: jest.fn(),
}));
jest.mock("../lib/hooks/useDraggablePane", () => ({
useDraggablePane: () => ({
height: 300,
handleDragStart: jest.fn(),
}),
useDraggableSidebar: () => ({
width: 320,
isDragging: false,
handleDragStart: jest.fn(),
}),
}));
jest.mock("../components/Sidebar", () => ({
__esModule: true,
default: () => <div>Sidebar</div>,
}));
jest.mock("../components/ResourcesTab", () => ({
__esModule: true,
default: () => <div>ResourcesTab</div>,
}));
jest.mock("../components/PromptsTab", () => ({
__esModule: true,
default: () => <div>PromptsTab</div>,
}));
jest.mock("../components/TasksTab", () => ({
__esModule: true,
default: () => <div>TasksTab</div>,
}));
jest.mock("../components/ConsoleTab", () => ({
__esModule: true,
default: () => <div>ConsoleTab</div>,
}));
jest.mock("../components/PingTab", () => ({
__esModule: true,
default: () => <div>PingTab</div>,
}));
jest.mock("../components/SamplingTab", () => ({
__esModule: true,
default: () => <div>SamplingTab</div>,
}));
jest.mock("../components/RootsTab", () => ({
__esModule: true,
default: () => <div>RootsTab</div>,
}));
jest.mock("../components/ElicitationTab", () => ({
__esModule: true,
default: () => <div>ElicitationTab</div>,
}));
jest.mock("../components/MetadataTab", () => ({
__esModule: true,
default: () => <div>MetadataTab</div>,
}));
jest.mock("../components/AuthDebugger", () => ({
__esModule: true,
default: () => <div>AuthDebugger</div>,
}));
jest.mock("../components/HistoryAndNotifications", () => ({
__esModule: true,
default: () => <div>HistoryAndNotifications</div>,
}));
jest.mock("../components/ToolsTab", () => ({
__esModule: true,
default: ({
listTools,
callTool,
}: {
listTools: () => void;
callTool: (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => Promise<unknown>;
}) => (
<div data-testid="tools-tab">
<button type="button" onClick={listTools}>
mock list tools
</button>
<button
type="button"
onClick={() => {
void callTool("weatherApp", { city: "Lisbon" });
}}
>
mock run app tool
</button>
</div>
),
}));
jest.mock("../components/AppsTab", () => ({
__esModule: true,
default: (props: AppsTabProps) => {
const prefilled =
props && "prefilledToolCall" in props ? props.prefilledToolCall : null;
const tools = props && "tools" in props ? props.tools : [];
return (
<div data-testid="apps-tab">
<div data-testid="apps-tools">{JSON.stringify(tools)}</div>
<div data-testid="apps-prefilled">
{JSON.stringify(prefilled ?? null)}
</div>
{prefilled && (
<button
type="button"
onClick={() => props?.onPrefilledToolCallConsumed?.(prefilled.id)}
>
consume prefilled
</button>
)}
</div>
);
},
}));
global.fetch = jest.fn().mockResolvedValue({ json: () => Promise.resolve({}) });
jest.mock("../lib/hooks/useConnection", () => ({
useConnection: jest.fn(),
}));
describe("App - Tools to Apps prefilled handoff", () => {
const mockUseConnection = jest.mocked(useConnection);
beforeEach(() => {
jest.clearAllMocks();
window.location.hash = "#tools";
});
it("passes prefilled call data to AppsTab for tools using _meta['ui/resourceUri']", async () => {
const makeRequest = jest.fn(async (request: { method: string }) => {
if (request.method === "tools/list") {
return {
tools: [
{
name: "weatherApp",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
},
},
_meta: {
"ui/resourceUri": "ui://weather-app",
},
},
],
nextCursor: undefined,
};
}
if (request.method === "tools/call") {
return {
content: [{ type: "text", text: "weather result" }],
};
}
throw new Error(`Unexpected method: ${request.method}`);
});
mockUseConnection.mockReturnValue({
connectionStatus: "connected",
serverCapabilities: { tools: { listChanged: true } },
serverImplementation: null,
mcpClient: {
request: jest.fn(),
notification: jest.fn(),
close: jest.fn(),
} as unknown as Client,
requestHistory: [],
clearRequestHistory: jest.fn(),
makeRequest,
cancelTask: jest.fn(),
listTasks: jest.fn(),
sendNotification: jest.fn(),
handleCompletion: jest.fn(),
completionsSupported: false,
connect: jest.fn(),
disconnect: jest.fn(),
} as ReturnType<typeof useConnection>);
render(<App />);
fireEvent.click(screen.getByRole("button", { name: /mock list tools/i }));
await waitFor(() => {
const tools = JSON.parse(
screen.getByTestId("apps-tools").textContent || "[]",
);
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe("weatherApp");
});
fireEvent.click(screen.getByRole("button", { name: /mock run app tool/i }));
await waitFor(() => {
const prefilled = JSON.parse(
screen.getByTestId("apps-prefilled").textContent || "null",
);
expect(prefilled.toolName).toBe("weatherApp");
expect(prefilled.params).toEqual({ city: "Lisbon" });
expect(prefilled.result.content[0].text).toBe("weather result");
});
fireEvent.click(screen.getByRole("button", { name: /consume prefilled/i }));
await waitFor(() => {
expect(screen.getByTestId("apps-prefilled")).toHaveTextContent("null");
});
});
});

View file

@ -3,6 +3,9 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import {
Tool,
ContentBlock,
CompatibilityCallToolResult,
CallToolResult,
CallToolResultSchema,
ServerNotification,
LoggingMessageNotificationParams,
} from "@modelcontextprotocol/sdk/types.js";
@ -24,6 +27,7 @@ interface AppRendererProps {
tool: Tool;
mcpClient: Client | null;
toolInput?: Record<string, unknown>;
toolResult?: CompatibilityCallToolResult | null;
onNotification?: (notification: ServerNotification) => void;
}
@ -32,11 +36,32 @@ const AppRenderer = ({
tool,
mcpClient,
toolInput,
toolResult,
onNotification,
}: AppRendererProps) => {
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
const normalizedToolResult = useMemo<CallToolResult | undefined>(() => {
if (!toolResult) {
return undefined;
}
if ("content" in toolResult) {
const parsedResult = CallToolResultSchema.safeParse(toolResult);
return parsedResult.success ? parsedResult.data : undefined;
}
if ("toolResult" in toolResult) {
const parsedResult = CallToolResultSchema.safeParse(
toolResult.toolResult,
);
return parsedResult.success ? parsedResult.data : undefined;
}
return undefined;
}, [toolResult]);
const hostContext: McpUiHostContext = useMemo(
() => ({
theme: document.documentElement.classList.contains("dark")
@ -115,6 +140,7 @@ const AppRenderer = ({
toolName={tool.name}
hostContext={hostContext}
toolInput={toolInput}
toolResult={normalizedToolResult}
sandbox={{
url: new URL(sandboxPath, window.location.origin),
}}

View file

@ -6,11 +6,16 @@ import {
AlertCircle,
X,
Play,
Loader2,
ChevronRight,
Maximize2,
Minimize2,
} from "lucide-react";
import { Tool, ServerNotification } from "@modelcontextprotocol/sdk/types.js";
import {
Tool,
ServerNotification,
CompatibilityCallToolResult,
} from "@modelcontextprotocol/sdk/types.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { getToolUiResourceUri } from "@modelcontextprotocol/ext-apps/app-bridge";
import AppRenderer from "./AppRenderer";
@ -40,6 +45,19 @@ interface AppsTabProps {
sandboxPath: string;
tools: Tool[];
listTools: () => void;
callTool: (
name: string,
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => Promise<CompatibilityCallToolResult>;
prefilledToolCall?: {
id: number;
toolName: string;
params: Record<string, unknown>;
result: CompatibilityCallToolResult;
} | null;
onPrefilledToolCallConsumed?: (callId: number) => void;
error: string | null;
mcpClient: Client | null;
onNotification?: (notification: ServerNotification) => void;
@ -50,10 +68,23 @@ const hasUIMetadata = (tool: Tool): boolean => {
return !!getToolUiResourceUri(tool);
};
const cloneToolParams = (
source: Record<string, unknown>,
): Record<string, unknown> => {
try {
return structuredClone(source);
} catch {
return { ...source };
}
};
const AppsTab = ({
sandboxPath,
tools,
listTools,
callTool,
prefilledToolCall,
onPrefilledToolCallConsumed,
error,
mcpClient,
onNotification,
@ -62,9 +93,38 @@ const AppsTab = ({
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
const [params, setParams] = useState<Record<string, unknown>>({});
const [isAppOpen, setIsAppOpen] = useState(false);
const [isOpeningApp, setIsOpeningApp] = useState(false);
const [isMaximized, setIsMaximized] = useState(false);
const [hasValidationErrors, setHasValidationErrors] = useState(false);
const [submittedParams, setSubmittedParams] = useState<
Record<string, unknown> | undefined
>(undefined);
const [submittedToolResult, setSubmittedToolResult] =
useState<CompatibilityCallToolResult | null>(null);
const formRefs = useRef<Record<string, DynamicJsonFormRef | null>>({});
const openAppRunIdRef = useRef(0);
const prefillingParamsRef = useRef<Record<string, unknown> | null>(null);
const consumedPrefilledCallIdRef = useRef<number | null>(null);
const buildInitialParams = useCallback((tool: Tool) => {
const initialParams = Object.entries(tool.inputSchema.properties ?? []).map(
([key, value]) => {
const resolvedValue = resolveRef(
value as JsonSchemaType,
tool.inputSchema as JsonSchemaType,
);
return [
key,
generateDefaultValue(
resolvedValue,
key,
tool.inputSchema as JsonSchemaType,
),
];
},
);
return Object.fromEntries(initialParams);
}, []);
// Function to check if any form has validation errors
const checkValidationErrors = useCallback(() => {
@ -89,63 +149,145 @@ const AppsTab = ({
if (selectedTool && !filtered.find((t) => t.name === selectedTool.name)) {
setSelectedTool(null);
setIsAppOpen(false);
setSubmittedParams(undefined);
setSubmittedToolResult(null);
}
}, [tools, selectedTool]);
useEffect(() => {
if (selectedTool) {
const initialParams = Object.entries(
selectedTool.inputSchema.properties ?? [],
).map(([key, value]) => {
// First resolve any $ref references
const resolvedValue = resolveRef(
value as JsonSchemaType,
selectedTool.inputSchema as JsonSchemaType,
);
return [
key,
generateDefaultValue(
resolvedValue,
key,
selectedTool.inputSchema as JsonSchemaType,
),
];
});
setParams(Object.fromEntries(initialParams));
const prefillingParams = prefillingParamsRef.current;
if (prefillingParams) {
setParams(prefillingParams);
prefillingParamsRef.current = null;
} else {
setParams(buildInitialParams(selectedTool));
}
setHasValidationErrors(false);
formRefs.current = {};
} else {
setParams({});
setIsAppOpen(false);
setSubmittedParams(undefined);
setSubmittedToolResult(null);
}
}, [selectedTool]);
}, [buildInitialParams, selectedTool]);
useEffect(() => {
if (!prefilledToolCall) {
return;
}
if (consumedPrefilledCallIdRef.current === prefilledToolCall.id) {
return;
}
const matchingTool = appTools.find(
(tool) => tool.name === prefilledToolCall.toolName,
);
if (!matchingTool) {
return;
}
const hydratedParams = cloneToolParams(prefilledToolCall.params);
openAppRunIdRef.current += 1;
setIsOpeningApp(false);
prefillingParamsRef.current = hydratedParams;
setSelectedTool(matchingTool);
setSubmittedParams(hydratedParams);
setSubmittedToolResult(prefilledToolCall.result);
setIsAppOpen(true);
setIsMaximized(false);
consumedPrefilledCallIdRef.current = prefilledToolCall.id;
onPrefilledToolCallConsumed?.(prefilledToolCall.id);
}, [appTools, onPrefilledToolCallConsumed, prefilledToolCall]);
const handleRefresh = useCallback(() => {
listTools();
}, [listTools]);
const executeToolAndOpenApp = useCallback(
async (tool: Tool, toolParams: Record<string, unknown>) => {
const runId = ++openAppRunIdRef.current;
const runParams = cloneToolParams(toolParams);
prefillingParamsRef.current = null;
setIsOpeningApp(true);
setSubmittedParams(runParams);
setSubmittedToolResult(null);
try {
const result = await callTool(tool.name, runParams);
if (runId !== openAppRunIdRef.current) {
return;
}
setSubmittedParams(runParams);
setSubmittedToolResult(result);
setIsAppOpen(true);
} catch {
if (runId !== openAppRunIdRef.current) {
return;
}
setSubmittedToolResult(null);
setIsAppOpen(false);
} finally {
if (runId === openAppRunIdRef.current) {
setIsOpeningApp(false);
}
}
},
[callTool],
);
const handleCloseApp = useCallback(() => {
openAppRunIdRef.current += 1;
setIsOpeningApp(false);
setIsAppOpen(false);
setSubmittedToolResult(null);
}, []);
const handleOpenApp = useCallback(() => {
if (!checkValidationErrors()) {
setIsAppOpen(true);
const handleOpenApp = useCallback(async () => {
if (!selectedTool || checkValidationErrors()) {
return;
}
}, [checkValidationErrors]);
const handleSelectTool = useCallback((tool: Tool) => {
setSelectedTool(tool);
const hasFields =
tool.inputSchema.properties &&
Object.keys(tool.inputSchema.properties).length > 0;
setIsAppOpen(!hasFields);
}, []);
await executeToolAndOpenApp(selectedTool, params);
}, [checkValidationErrors, executeToolAndOpenApp, params, selectedTool]);
const handleSelectTool = useCallback(
(tool: Tool) => {
openAppRunIdRef.current += 1;
setIsOpeningApp(false);
prefillingParamsRef.current = null;
setSelectedTool(tool);
setSubmittedParams(undefined);
setSubmittedToolResult(null);
const hasFields =
tool.inputSchema.properties &&
Object.keys(tool.inputSchema.properties).length > 0;
if (hasFields) {
setIsAppOpen(false);
return;
}
const initialParams = buildInitialParams(tool);
void executeToolAndOpenApp(tool, initialParams);
},
[buildInitialParams, executeToolAndOpenApp],
);
const handleDeselectTool = useCallback(() => {
openAppRunIdRef.current += 1;
setIsOpeningApp(false);
prefillingParamsRef.current = null;
setSelectedTool(null);
setIsAppOpen(false);
setIsMaximized(false);
setSubmittedParams(undefined);
setSubmittedToolResult(null);
}, []);
return (
@ -470,12 +612,16 @@ const AppsTab = ({
})}
<Button
onClick={handleOpenApp}
onClick={() => void handleOpenApp()}
className="w-full"
disabled={hasValidationErrors}
disabled={hasValidationErrors || isOpeningApp}
>
<Play className="w-4 h-4 mr-2" />
Open App
{isOpeningApp ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Play className="w-4 h-4 mr-2" />
)}
{isOpeningApp ? "Opening App..." : "Open App"}
</Button>
</div>
</div>
@ -497,7 +643,8 @@ const AppsTab = ({
sandboxPath={sandboxPath}
tool={selectedTool}
mcpClient={mcpClient}
toolInput={params}
toolInput={submittedParams}
toolResult={submittedToolResult}
onNotification={onNotification}
/>
</div>

View file

@ -157,7 +157,7 @@ const ToolsTab = ({
params: Record<string, unknown>,
metadata?: Record<string, unknown>,
runAsTask?: boolean,
) => Promise<void>;
) => Promise<CompatibilityCallToolResult>;
selectedTool: Tool | null;
setSelectedTool: (tool: Tool | null) => void;
toolResult: CompatibilityCallToolResult | null;

View file

@ -1,12 +1,33 @@
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,
CompatibilityCallToolResult,
} 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?: CompatibilityCallToolResult;
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 +45,74 @@ 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>
);
},
};
});
describe("AppRenderer", () => {
const mockTool: Tool = {
@ -69,7 +130,6 @@ describe("AppRenderer", () => {
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockMcpClient = {
request: jest.fn(),
getServerCapabilities: jest.fn().mockReturnValue({}),
setNotificationHandler: jest.fn(),
} as unknown as Client;
@ -78,10 +138,12 @@ describe("AppRenderer", () => {
sandboxPath: "/sandbox",
tool: mockTool,
mcpClient: mockMcpClient,
toolResult: null,
};
beforeEach(() => {
jest.clearAllMocks();
mockBridgeEvents.length = 0;
});
it("should display waiting state when mcpClient is null", () => {
@ -112,4 +174,91 @@ describe("AppRenderer", () => {
description: "Test message",
});
});
it("should send provided tool input and tool result after app initialization", async () => {
const result: CompatibilityCallToolResult = {
content: [{ type: "text", text: "Budget initialized" }],
};
render(
<AppRenderer
{...defaultProps}
toolInput={{ monthlyBudget: 2500 }}
toolResult={result}
/>,
);
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(result);
});
it("should not send tool result event when toolResult is null", async () => {
render(
<AppRenderer
{...defaultProps}
toolInput={{ monthlyBudget: 2500 }}
toolResult={null}
/>,
);
fireEvent.click(screen.getByTestId("initialize-app"));
await waitFor(() => {
const inputEvents = mockBridgeEvents.filter(
(event) =>
event.toolName === "testApp" && event.type === "sendToolInput",
);
expect(inputEvents).toHaveLength(1);
});
const resultEvents = mockBridgeEvents.filter(
(event) =>
event.toolName === "testApp" && event.type === "sendToolResult",
);
expect(resultEvents).toHaveLength(0);
});
it("should normalize compatibility wrapper tool results before sending", async () => {
const wrappedResult = {
toolResult: {
content: [{ type: "text", text: "Wrapped result payload" }],
},
} as CompatibilityCallToolResult;
render(
<AppRenderer
{...defaultProps}
toolInput={{ monthlyBudget: 2500 }}
toolResult={wrappedResult}
/>,
);
fireEvent.click(screen.getByTestId("initialize-app"));
await waitFor(() => {
const resultEvent = mockBridgeEvents.find(
(event) =>
event.toolName === "testApp" && event.type === "sendToolResult",
);
expect(resultEvent).toBeTruthy();
expect(resultEvent?.payload).toEqual(wrappedResult.toolResult);
});
});
});

View file

@ -1,8 +1,17 @@
import { render, screen, fireEvent } from "@testing-library/react";
import {
render,
screen,
fireEvent,
waitFor,
act,
} from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach } from "@jest/globals";
import AppsTab from "../AppsTab";
import { Tool } from "@modelcontextprotocol/sdk/types.js";
import {
Tool,
CompatibilityCallToolResult,
} from "@modelcontextprotocol/sdk/types.js";
import { Tabs } from "../ui/tabs";
// Mock AppRenderer component
@ -10,14 +19,19 @@ jest.mock("../AppRenderer", () => {
return function MockAppRenderer({
tool,
toolInput,
toolResult,
}: {
tool: Tool;
toolInput?: Record<string, unknown>;
toolResult?: unknown;
}) {
return (
<div data-testid="app-renderer">
<div>Tool: {tool.name}</div>
<div data-testid="tool-input">{JSON.stringify(toolInput)}</div>
<div data-testid="tool-result">
{JSON.stringify(toolResult ?? null)}
</div>
</div>
);
};
@ -50,6 +64,12 @@ describe("AppsTab", () => {
const defaultProps = {
tools: [],
listTools: jest.fn(),
callTool: jest.fn(
async () =>
({
content: [],
}) as CompatibilityCallToolResult,
),
error: null,
mcpClient: null,
};
@ -131,7 +151,7 @@ describe("AppsTab", () => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
it("should open app renderer when an app card is clicked and Open App button is clicked if fields exist", () => {
it("should open app renderer when an app card is clicked and Open App button is clicked if fields exist", async () => {
const toolWithFields: Tool = {
name: "fieldsApp",
inputSchema: {
@ -142,9 +162,16 @@ describe("AppsTab", () => {
},
_meta: { ui: { resourceUri: "ui://fields" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockCallTool = jest.fn(
async () =>
({
content: [],
}) as CompatibilityCallToolResult,
);
renderAppsTab({
tools: [toolWithFields],
callTool: mockCallTool,
});
const appCard = screen.getByText("fieldsApp").closest("div");
@ -159,12 +186,19 @@ describe("AppsTab", () => {
const openAppButton = screen.getByRole("button", { name: /open app/i });
fireEvent.click(openAppButton);
// AppRenderer should be rendered
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
await waitFor(() => {
expect(mockCallTool).toHaveBeenCalledWith("fieldsApp", {
field1: undefined,
});
});
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
expect(screen.getByText("Tool: fieldsApp")).toBeInTheDocument();
});
it("should close app renderer when close button is clicked", () => {
it("should close app renderer when close button is clicked", async () => {
renderAppsTab({
tools: [mockAppTool],
});
@ -172,8 +206,9 @@ describe("AppsTab", () => {
// Open the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
// Close the app (Deselect tool)
const closeButton = screen.getByRole("button", { name: /close app/i });
@ -204,7 +239,7 @@ describe("AppsTab", () => {
expect(screen.getByText("noDescriptionApp")).toBeInTheDocument();
});
it("should reset selected tool when tools list changes and selected tool is removed", () => {
it("should reset selected tool when tools list changes and selected tool is removed", async () => {
const { rerender } = renderAppsTab({
tools: [mockAppTool],
});
@ -212,8 +247,9 @@ describe("AppsTab", () => {
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
// Update tools list to remove the selected tool
rerender(
@ -226,7 +262,7 @@ describe("AppsTab", () => {
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
});
it("should maintain selected tool when tools list updates but includes the same tool", () => {
it("should maintain selected tool when tools list updates but includes the same tool", async () => {
const { rerender } = renderAppsTab({
tools: [mockAppTool],
});
@ -234,8 +270,9 @@ describe("AppsTab", () => {
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
// Update tools list with the same tool
rerender(
@ -248,7 +285,7 @@ describe("AppsTab", () => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
it("should maximize and minimize the app window", () => {
it("should maximize and minimize the app window", async () => {
renderAppsTab({
tools: [mockAppTool],
});
@ -256,12 +293,12 @@ describe("AppsTab", () => {
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
// Now Maximize button should be visible
expect(
screen.getByRole("button", { name: /maximize/i }),
).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByRole("button", { name: /maximize/i }),
).toBeInTheDocument();
});
// Initially, ListPane should be visible
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
@ -281,7 +318,7 @@ describe("AppsTab", () => {
expect(screen.getByText("MCP Apps")).toBeInTheDocument();
});
it("should reset maximized state when app is closed", () => {
it("should reset maximized state when app is closed", async () => {
renderAppsTab({
tools: [mockAppTool],
});
@ -289,7 +326,12 @@ describe("AppsTab", () => {
// Select the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
await waitFor(() => {
expect(
screen.getByRole("button", { name: /maximize/i }),
).toBeInTheDocument();
});
// Maximize
fireEvent.click(screen.getByRole("button", { name: /maximize/i }));
@ -311,7 +353,7 @@ describe("AppsTab", () => {
expect(appItem).toBeTruthy();
});
it("should handle empty resourceContentMap", () => {
it("should handle empty resourceContentMap", async () => {
renderAppsTab({
tools: [mockAppTool],
});
@ -319,13 +361,13 @@ describe("AppsTab", () => {
// Open the app
const appCard = screen.getByText("weatherApp").closest("div");
fireEvent.click(appCard!);
// weatherApp has no properties in mockAppTool, so it should be open immediately
// AppRenderer should still be rendered
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
});
it("should handle various input types and pass them to AppRenderer", () => {
it("should handle various input types and pass them to AppRenderer", async () => {
const toolWithComplexSchema: Tool = {
name: "complexApp",
inputSchema: {
@ -359,6 +401,10 @@ describe("AppsTab", () => {
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const toolInput = JSON.parse(
screen.getByTestId("tool-input").textContent || "{}",
);
@ -367,7 +413,7 @@ describe("AppsTab", () => {
expect(toolInput.number).toBe(42);
});
it("should handle nullable fields", () => {
it("should handle nullable fields", async () => {
const toolWithNullable: Tool = {
name: "nullableApp",
inputSchema: {
@ -391,13 +437,17 @@ describe("AppsTab", () => {
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const toolInput = JSON.parse(
screen.getByTestId("tool-input").textContent || "{}",
);
expect(toolInput.nullableField).toBe(null);
});
it("should allow going back to input form from app renderer", () => {
it("should allow going back to input form from app renderer", async () => {
const toolWithFields: Tool = {
name: "fieldsApp",
inputSchema: {
@ -416,13 +466,15 @@ describe("AppsTab", () => {
fireEvent.click(screen.getByText("fieldsApp"));
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const backButton = screen.queryByRole("button", { name: /back to input/i });
expect(backButton).toBeInTheDocument();
});
it("should skip input form if tool has no input fields", () => {
it("should skip input form if tool has no input fields", async () => {
const toolNoFields: Tool = {
name: "noFieldsApp",
inputSchema: {
@ -431,15 +483,27 @@ describe("AppsTab", () => {
},
_meta: { ui: { resourceUri: "ui://no-fields" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockCallTool = jest.fn(
async () =>
({
content: [],
}) as CompatibilityCallToolResult,
);
renderAppsTab({
tools: [toolNoFields],
callTool: mockCallTool,
});
fireEvent.click(screen.getByText("noFieldsApp"));
// Should see the renderer immediately
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
await waitFor(() => {
expect(mockCallTool).toHaveBeenCalledWith("noFieldsApp", {});
});
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
expect(screen.getByText("Tool: noFieldsApp")).toBeInTheDocument();
// Should NOT see the back button
@ -448,7 +512,7 @@ describe("AppsTab", () => {
).not.toBeInTheDocument();
});
it("should allow going back to input form from app renderer if fields exist", () => {
it("should allow going back to input form from app renderer if fields exist", async () => {
const toolWithFields: Tool = {
name: "fieldsApp",
inputSchema: {
@ -470,7 +534,9 @@ describe("AppsTab", () => {
expect(screen.getByText("App Input")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const backButton = screen.getByRole("button", { name: /back to input/i });
fireEvent.click(backButton);
@ -478,4 +544,229 @@ describe("AppsTab", () => {
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
expect(screen.getByText("App Input")).toBeInTheDocument();
});
it("should auto-render app from prefilled tools tab call without manual click", async () => {
const toolWithFields: Tool = {
name: "prefilledApp",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://prefilled" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const onPrefilledToolCallConsumed = jest.fn();
const prefilledResult: CompatibilityCallToolResult = {
content: [{ type: "text", text: "weather result" }],
};
renderAppsTab({
tools: [toolWithFields],
prefilledToolCall: {
id: 42,
toolName: "prefilledApp",
params: { city: "Lisbon" },
result: prefilledResult,
},
onPrefilledToolCallConsumed,
});
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
expect(screen.getByText("Tool: prefilledApp")).toBeInTheDocument();
});
const toolInput = JSON.parse(
screen.getByTestId("tool-input").textContent || "{}",
);
expect(toolInput).toEqual({ city: "Lisbon" });
expect(screen.getByTestId("tool-result")).toHaveTextContent(
"weather result",
);
expect(onPrefilledToolCallConsumed).toHaveBeenCalledWith(42);
});
it("should not auto-render app when no prefilled tool call is provided", () => {
const toolWithFields: Tool = {
name: "manualApp",
inputSchema: {
type: "object",
properties: {
city: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://manual" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
renderAppsTab({
tools: [toolWithFields],
});
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
expect(
screen.getByText("Select an app from the list to get started"),
).toBeInTheDocument();
});
it("should preserve submitted params while opening app", async () => {
const toolWithFields: Tool = {
name: "paramsApp",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://params" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
let resolveCall: ((result: CompatibilityCallToolResult) => void) | null =
null;
const mockCallTool = jest.fn(
() =>
new Promise<CompatibilityCallToolResult>((resolve) => {
resolveCall = resolve;
}),
);
renderAppsTab({
tools: [toolWithFields],
callTool: mockCallTool,
});
fireEvent.click(screen.getByText("paramsApp"));
fireEvent.change(screen.getByLabelText("query"), {
target: { value: "first value" },
});
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
fireEvent.change(screen.getByLabelText("query"), {
target: { value: "second value" },
});
expect(resolveCall).toBeTruthy();
await act(async () => {
resolveCall?.({
content: [{ type: "text", text: "done" }],
});
});
await waitFor(() => {
expect(screen.getByTestId("app-renderer")).toBeInTheDocument();
});
const toolInput = JSON.parse(
screen.getByTestId("tool-input").textContent || "{}",
);
expect(toolInput.query).toBe("first value");
});
it("should keep input view when opening fails and recover button state", async () => {
const toolWithFields: Tool = {
name: "failingApp",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
},
},
_meta: { ui: { resourceUri: "ui://failing" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const mockCallTool = jest.fn(
async () =>
await Promise.reject<CompatibilityCallToolResult>(
new Error("tool failed"),
),
);
renderAppsTab({
tools: [toolWithFields],
callTool: mockCallTool,
});
fireEvent.click(screen.getByText("failingApp"));
fireEvent.click(screen.getByRole("button", { name: /open app/i }));
await waitFor(() => {
expect(mockCallTool).toHaveBeenCalledWith("failingApp", {
query: undefined,
});
});
await waitFor(() => {
const openAppButton = screen.getByRole("button", { name: /open app/i });
expect(openAppButton).toBeEnabled();
});
expect(screen.getByText("App Input")).toBeInTheDocument();
expect(screen.queryByTestId("app-renderer")).not.toBeInTheDocument();
});
it("should ignore stale results from older app launches", async () => {
const appA: Tool = {
name: "appA",
inputSchema: {
type: "object",
properties: {},
},
_meta: { ui: { resourceUri: "ui://app-a" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const appB: Tool = {
name: "appB",
inputSchema: {
type: "object",
properties: {},
},
_meta: { ui: { resourceUri: "ui://app-b" } },
} as Tool & { _meta?: { ui?: { resourceUri?: string } } };
const pendingCalls: {
name: string;
resolve: (result: CompatibilityCallToolResult) => void;
}[] = [];
const mockCallTool = jest.fn(
(name: string) =>
new Promise<CompatibilityCallToolResult>((resolve) => {
pendingCalls.push({ name, resolve });
}),
);
renderAppsTab({
tools: [appA, appB],
callTool: mockCallTool,
});
fireEvent.click(screen.getByText("appA"));
fireEvent.click(screen.getByText("appB"));
const appBCall = pendingCalls.find((call) => call.name === "appB");
const appACall = pendingCalls.find((call) => call.name === "appA");
expect(appBCall).toBeTruthy();
expect(appACall).toBeTruthy();
await act(async () => {
appBCall?.resolve({
content: [{ type: "text", text: "result from appB" }],
});
});
await waitFor(() => {
expect(screen.getByText("Tool: appB")).toBeInTheDocument();
expect(screen.getByTestId("tool-result")).toHaveTextContent("appB");
});
await act(async () => {
appACall?.resolve({
content: [{ type: "text", text: "result from appA" }],
});
});
await waitFor(() => {
expect(screen.getByText("Tool: appB")).toBeInTheDocument();
expect(screen.getByTestId("tool-result")).toHaveTextContent("appB");
expect(screen.getByTestId("tool-result")).not.toHaveTextContent("appA");
});
});
});