diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 9b793eae6..3ee647fe3 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -13,8 +13,17 @@ module.exports = { tsconfig: "tsconfig.jest.json", }, ], + "^.+\\.m?js$": [ + "ts-jest", + { + tsconfig: "tsconfig.jest.json", + }, + ], }, extensionsToTreatAsEsm: [".ts", ".tsx"], + transformIgnorePatterns: [ + "node_modules/(?!(@modelcontextprotocol)/)", + ], testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$", // Exclude directories and files that don't need to be tested testPathIgnorePatterns: [ diff --git a/client/package.json b/client/package.json index 1feec2122..0d2a6a26e 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,7 @@ "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.0", "@modelcontextprotocol/sdk": "^1.25.2", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", diff --git a/client/src/App.tsx b/client/src/App.tsx index 39fc2812a..0a8d536a4 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -51,6 +51,7 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { + AppWindow, Bell, Files, FolderTree, @@ -75,6 +76,7 @@ import SamplingTab, { PendingRequest } from "./components/SamplingTab"; import Sidebar from "./components/Sidebar"; import ToolsTab from "./components/ToolsTab"; import TasksTab from "./components/TasksTab"; +import AppsTab from "./components/AppsTab"; import { InspectorConfig } from "./lib/configurationTypes"; import { getMCPProxyAddress, @@ -308,11 +310,13 @@ const App = () => { ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []), + "apps", "ping", "sampling", "elicitations", "roots", "auth", + "metadata", ]; if (!validTabs.includes(originatingTab)) return; @@ -440,11 +444,13 @@ const App = () => { ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []), + "apps", "ping", "sampling", "elicitations", "roots", "auth", + "metadata", ]; const isValidTab = validTabs.includes(hash); @@ -473,6 +479,13 @@ const App = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [mcpClient, activeTab]); + useEffect(() => { + if (mcpClient && activeTab === "apps" && serverCapabilities?.tools) { + void listTools(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mcpClient, activeTab, serverCapabilities?.tools]); + useEffect(() => { localStorage.setItem("lastCommand", command); }, [command]); @@ -757,11 +770,13 @@ const App = () => { ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []), + "apps", "ping", "sampling", "elicitations", "roots", "auth", + "metadata", ]; if (validTabs.includes(originatingTab)) { @@ -851,6 +866,7 @@ const App = () => { }; const readResource = async (uri: string) => { + console.log("[App] Reading resource:", uri); lastToolCallOriginTabRef.current = currentTabRef.current; const response = await sendMCPRequest( @@ -861,12 +877,25 @@ const App = () => { ReadResourceResultSchema, "resources", ); + console.log("[App] Resource read response:", { + uri, + responseLength: JSON.stringify(response).length, + hasContents: !!(response as { contents?: unknown[] }).contents, + }); const content = JSON.stringify(response, null, 2); setResourceContent(content); - setResourceContentMap((prev) => ({ - ...prev, - [uri]: content, - })); + setResourceContentMap((prev) => { + const updated = { + ...prev, + [uri]: content, + }; + console.log("[App] Updated resourceContentMap:", { + uri, + contentLength: content.length, + mapKeys: Object.keys(updated), + }); + return updated; + }); }; const subscribeToResource = async (uri: string) => { @@ -1308,6 +1337,10 @@ const App = () => { Tasks + + + Apps + Ping @@ -1497,6 +1530,20 @@ const App = () => { error={errors.tasks} nextCursor={nextTaskCursor} /> + { + clearError("tools"); + listTools(); + }} + error={errors.tools} + mcpClient={mcpClient} + onReadResource={(uri: string) => { + clearError("resources"); + readResource(uri); + }} + resourceContentMap={resourceContentMap} + /> { diff --git a/client/src/components/AppRenderer.tsx b/client/src/components/AppRenderer.tsx new file mode 100644 index 000000000..c9a1a2023 --- /dev/null +++ b/client/src/components/AppRenderer.tsx @@ -0,0 +1,265 @@ +import { useEffect, useRef, useState } from "react"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { + AppBridge, + PostMessageTransport, + getToolUiResourceUri, + buildAllowAttribute, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle, Loader2 } from "lucide-react"; + +interface AppRendererProps { + tool: Tool; + mcpClient: Client | null; + onReadResource: (uri: string) => void; + resourceContent: string; +} + +interface UIResourceMeta { + ui?: { + resourceUri?: string; + permissions?: Record; + csp?: string; + }; +} + +const AppRenderer = ({ + tool, + mcpClient, + onReadResource, + resourceContent, +}: AppRendererProps) => { + const iframeRef = useRef(null); + const bridgeRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [initialized, setInitialized] = useState(false); + + // Extract UI metadata from tool + const resourceUri = getToolUiResourceUri(tool); + const meta = (tool as Tool & { _meta?: UIResourceMeta })._meta; + const permissions = meta?.ui?.permissions; + + // Fetch UI resource when component mounts or tool changes + useEffect(() => { + console.log("[AppRenderer] Resource fetch check:", { + resourceUri, + hasResourceContent: !!resourceContent, + resourceContentLength: resourceContent?.length || 0, + }); + + if (resourceUri && !resourceContent) { + console.log("[AppRenderer] Fetching resource:", resourceUri); + setLoading(true); + setError(null); + onReadResource(resourceUri); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resourceUri, resourceContent]); + + // Set up AppBridge and render the app + useEffect(() => { + console.log("[AppRenderer] Setup check:", { + hasResourceContent: !!resourceContent, + hasIframe: !!iframeRef.current, + hasMcpClient: !!mcpClient, + resourceContentPreview: resourceContent?.substring(0, 100), + }); + + if (!resourceContent || !iframeRef.current || !mcpClient) { + console.log("[AppRenderer] Setup conditions not met, skipping"); + return; + } + + console.log("[AppRenderer] Starting app setup"); + const iframe = iframeRef.current; + let bridge: AppBridge | null = null; + let transport: PostMessageTransport | null = null; + + const setupApp = async () => { + try { + console.log("[AppRenderer] Creating AppBridge..."); + // Create AppBridge with the MCP client + bridge = new AppBridge( + mcpClient, + { name: "MCP Inspector", version: "0.19.0" }, + { + openLinks: {}, + serverTools: {}, + logging: {}, + }, + { + hostContext: { + theme: document.documentElement.classList.contains("dark") + ? "dark" + : "light", + }, + }, + ); + + // Set up event handlers + bridge.oninitialized = () => { + console.log("[AppRenderer] MCP App initialized successfully"); + setInitialized(true); + }; + + bridge.onerror = (error: Error) => { + console.error("MCP App error:", error); + setError(error.message || "An error occurred"); + }; + + // Create the iframe document with the UI resource content + const iframeDoc = + iframe.contentDocument || iframe.contentWindow?.document; + if (!iframeDoc) { + throw new Error("Could not access iframe document"); + } + + // Parse the resource content to extract HTML + let htmlContent = resourceContent; + console.log("[AppRenderer] Parsing resource content..."); + try { + const parsed = JSON.parse(resourceContent); + console.log("[AppRenderer] Parsed JSON:", { + hasContents: !!parsed.contents, + isArray: Array.isArray(parsed.contents), + contentsLength: parsed.contents?.length, + }); + if (parsed.contents && Array.isArray(parsed.contents)) { + // MCP resource response format: TextResourceContents has uri, mimeType?, and text + const textContent = parsed.contents.find( + (c: { uri?: string; mimeType?: string; text?: string }) => + c.text !== undefined, + ); + if (textContent?.text) { + htmlContent = textContent.text; + console.log( + "[AppRenderer] Extracted HTML from contents, length:", + htmlContent.length, + "mimeType:", + textContent.mimeType, + ); + } else { + console.warn( + "[AppRenderer] No text content found in contents array:", + parsed.contents, + ); + } + } + } catch (err) { + console.log("[AppRenderer] Not JSON, using content as-is:", err); + } + + // Write the HTML content to the iframe + console.log("[AppRenderer] Writing HTML to iframe..."); + iframeDoc.open(); + iframeDoc.write(htmlContent); + iframeDoc.close(); + console.log("[AppRenderer] HTML written to iframe"); + + // Clear loading state so iframe becomes visible + // This is required for PostMessage communication to work + setLoading(false); + console.log("[AppRenderer] Iframe now visible, ready for PostMessage"); + + // Wait for iframe to load + await new Promise((resolve) => { + if (iframe.contentWindow) { + iframe.contentWindow.addEventListener("load", () => resolve(), { + once: true, + }); + } else { + resolve(); + } + }); + + // Create PostMessageTransport for communication + if (!iframe.contentWindow) { + throw new Error("Iframe contentWindow not available"); + } + + console.log("[AppRenderer] Creating PostMessageTransport..."); + transport = new PostMessageTransport( + iframe.contentWindow, + iframe.contentWindow, + ); + + // Connect the bridge + console.log("[AppRenderer] Connecting AppBridge..."); + await bridge.connect(transport); + console.log("[AppRenderer] AppBridge connected successfully"); + + bridgeRef.current = bridge; + } catch (err) { + console.error("Error setting up MCP App:", err); + setError(err instanceof Error ? err.message : "Failed to set up app"); + setLoading(false); + } + }; + + setupApp(); + + // Cleanup + return () => { + if (bridge) { + bridge.close().catch(console.error); + } + bridgeRef.current = null; + setInitialized(false); + }; + }, [resourceContent, mcpClient]); + + // Build iframe attributes + const allowAttribute = buildAllowAttribute(permissions); + + if (!resourceUri) { + return ( + + + + No UI resource URI found in tool metadata + + + ); + } + + return ( +
+ {loading && ( + + + Loading MCP App... + + )} + + {error && ( + + + {error} + + )} + +