diff --git a/client/src/App.tsx b/client/src/App.tsx index 5a69495b..184f04d9 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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; + result: CompatibilityCallToolResult; +}; + +const hasAppResourceUri = (tool: Tool): boolean => { + return Boolean(getToolUiResourceUri(tool)); +}; + +const cloneToolParams = ( + source: Record, +): Record => { + try { + return structuredClone(source); + } catch { + return { ...source }; + } +}; + const filterReservedMetadata = ( metadata: Record, ): Record => { @@ -137,6 +159,8 @@ const App = () => { const [tasks, setTasks] = useState([]); const [toolResult, setToolResult] = useState(null); + const [prefilledAppsToolCall, setPrefilledAppsToolCall] = + useState(null); const [errors, setErrors] = useState>({ resources: null, prompts: null, @@ -291,6 +315,7 @@ const App = () => { const [nextToolCursor, setNextToolCursor] = useState(); const [nextTaskCursor, setNextTaskCursor] = useState(); const progressTokenRef = useRef(0); + const prefilledAppsToolCallIdRef = useRef(0); const [activeTab, setActiveTab] = useState(() => { const hash = window.location.hash.slice(1); @@ -977,7 +1002,7 @@ const App = () => { params: Record, toolMetadata?: Record, runAsTask?: boolean, - ) => { + ): Promise => { lastToolCallOriginTabRef.current = currentTabRef.current; try { @@ -1045,7 +1070,7 @@ const App = () => { "_meta" in (response as Record) ? ((response as { _meta?: Record })._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 })._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, + metadata?: Record, + 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) => { diff --git a/client/src/__tests__/App.toolsAppsPrefill.test.tsx b/client/src/__tests__/App.toolsAppsPrefill.test.tsx new file mode 100644 index 00000000..9239f29d --- /dev/null +++ b/client/src/__tests__/App.toolsAppsPrefill.test.tsx @@ -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; + }; + _meta: Record; +}; + +type AppsTabProps = { + tools: ToolListEntry[]; + prefilledToolCall?: { + id: number; + toolName: string; + params: Record; + 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: () =>
Sidebar
, +})); + +jest.mock("../components/ResourcesTab", () => ({ + __esModule: true, + default: () =>
ResourcesTab
, +})); + +jest.mock("../components/PromptsTab", () => ({ + __esModule: true, + default: () =>
PromptsTab
, +})); + +jest.mock("../components/TasksTab", () => ({ + __esModule: true, + default: () =>
TasksTab
, +})); + +jest.mock("../components/ConsoleTab", () => ({ + __esModule: true, + default: () =>
ConsoleTab
, +})); + +jest.mock("../components/PingTab", () => ({ + __esModule: true, + default: () =>
PingTab
, +})); + +jest.mock("../components/SamplingTab", () => ({ + __esModule: true, + default: () =>
SamplingTab
, +})); + +jest.mock("../components/RootsTab", () => ({ + __esModule: true, + default: () =>
RootsTab
, +})); + +jest.mock("../components/ElicitationTab", () => ({ + __esModule: true, + default: () =>
ElicitationTab
, +})); + +jest.mock("../components/MetadataTab", () => ({ + __esModule: true, + default: () =>
MetadataTab
, +})); + +jest.mock("../components/AuthDebugger", () => ({ + __esModule: true, + default: () =>
AuthDebugger
, +})); + +jest.mock("../components/HistoryAndNotifications", () => ({ + __esModule: true, + default: () =>
HistoryAndNotifications
, +})); + +jest.mock("../components/ToolsTab", () => ({ + __esModule: true, + default: ({ + listTools, + callTool, + }: { + listTools: () => void; + callTool: ( + name: string, + params: Record, + metadata?: Record, + runAsTask?: boolean, + ) => Promise; + }) => ( +
+ + +
+ ), +})); + +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 ( +
+
{JSON.stringify(tools)}
+
+ {JSON.stringify(prefilled ?? null)} +
+ {prefilled && ( + + )} +
+ ); + }, +})); + +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); + + render(); + + 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"); + }); + }); +}); diff --git a/client/src/components/AppRenderer.tsx b/client/src/components/AppRenderer.tsx index e0c25980..e25f35c9 100644 --- a/client/src/components/AppRenderer.tsx +++ b/client/src/components/AppRenderer.tsx @@ -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; + toolResult?: CompatibilityCallToolResult | null; onNotification?: (notification: ServerNotification) => void; } @@ -32,11 +36,32 @@ const AppRenderer = ({ tool, mcpClient, toolInput, + toolResult, onNotification, }: AppRendererProps) => { const [error, setError] = useState(null); const { toast } = useToast(); + const normalizedToolResult = useMemo(() => { + 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), }} diff --git a/client/src/components/AppsTab.tsx b/client/src/components/AppsTab.tsx index 1943c439..2b6d7535 100644 --- a/client/src/components/AppsTab.tsx +++ b/client/src/components/AppsTab.tsx @@ -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, + metadata?: Record, + runAsTask?: boolean, + ) => Promise; + prefilledToolCall?: { + id: number; + toolName: string; + params: Record; + 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, +): Record => { + 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(null); const [params, setParams] = useState>({}); 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 | undefined + >(undefined); + const [submittedToolResult, setSubmittedToolResult] = + useState(null); const formRefs = useRef>({}); + const openAppRunIdRef = useRef(0); + const prefillingParamsRef = useRef | null>(null); + const consumedPrefilledCallIdRef = useRef(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) => { + 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 = ({ })} @@ -497,7 +643,8 @@ const AppsTab = ({ sandboxPath={sandboxPath} tool={selectedTool} mcpClient={mcpClient} - toolInput={params} + toolInput={submittedParams} + toolResult={submittedToolResult} onNotification={onNotification} /> diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 3d4284e8..339ba7c6 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -157,7 +157,7 @@ const ToolsTab = ({ params: Record, metadata?: Record, runAsTask?: boolean, - ) => Promise; + ) => Promise; selectedTool: Tool | null; setSelectedTool: (tool: Tool | null) => void; toolResult: CompatibilityCallToolResult | null; diff --git a/client/src/components/__tests__/AppRenderer.test.tsx b/client/src/components/__tests__/AppRenderer.test.tsx index beb57e1a..7d9ae060 100644 --- a/client/src/components/__tests__/AppRenderer.test.tsx +++ b/client/src/components/__tests__/AppRenderer.test.tsx @@ -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; + toolResult?: CompatibilityCallToolResult; + 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 +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; - }) => ( -
-
{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)} +
+ + +
+ ); + }, + }; +}); 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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); + }); + }); }); diff --git a/client/src/components/__tests__/AppsTab.test.tsx b/client/src/components/__tests__/AppsTab.test.tsx index 27868fca..315a95cc 100644 --- a/client/src/components/__tests__/AppsTab.test.tsx +++ b/client/src/components/__tests__/AppsTab.test.tsx @@ -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; + toolResult?: unknown; }) { return (
Tool: {tool.name}
{JSON.stringify(toolInput)}
+
+ {JSON.stringify(toolResult ?? null)} +
); }; @@ -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((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( + 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((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"); + }); + }); });