diff --git a/client/jest.config.cjs b/client/jest.config.cjs index 9b793eae6..704852961 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -13,8 +13,15 @@ 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..1ac32027c 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,8 @@ "cleanup:e2e": "node e2e/global-teardown.js" }, "dependencies": { + "@mcp-ui/client": "^6.0.0", + "@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..5a69495b6 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, @@ -126,6 +128,9 @@ const App = () => { const [resourceContentMap, setResourceContentMap] = useState< Record >({}); + const [fetchingResources, setFetchingResources] = useState>( + new Set(), + ); const [prompts, setPrompts] = useState([]); const [promptContent, setPromptContent] = useState(""); const [tools, setTools] = useState([]); @@ -308,11 +313,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 +447,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 +482,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 +773,13 @@ const App = () => { ...(serverCapabilities?.prompts ? ["prompts"] : []), ...(serverCapabilities?.tools ? ["tools"] : []), ...(serverCapabilities?.tasks ? ["tasks"] : []), + "apps", "ping", "sampling", "elicitations", "roots", "auth", + "metadata", ]; if (validTabs.includes(originatingTab)) { @@ -851,22 +869,48 @@ const App = () => { }; const readResource = async (uri: string) => { + if (fetchingResources.has(uri) || resourceContentMap[uri]) { + return; + } + + console.log("[App] Reading resource:", uri); + setFetchingResources((prev) => new Set(prev).add(uri)); lastToolCallOriginTabRef.current = currentTabRef.current; - const response = await sendMCPRequest( - { - method: "resources/read" as const, - params: { uri }, - }, - ReadResourceResultSchema, - "resources", - ); - const content = JSON.stringify(response, null, 2); - setResourceContent(content); - setResourceContentMap((prev) => ({ - ...prev, - [uri]: content, - })); + try { + const response = await sendMCPRequest( + { + method: "resources/read" as const, + params: { uri }, + }, + 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, + })); + } catch (error) { + console.error(`[App] Failed to read resource ${uri}:`, error); + const errorString = (error as Error).message ?? String(error); + setResourceContentMap((prev) => ({ + ...prev, + [uri]: JSON.stringify({ error: errorString }), + })); + } finally { + setFetchingResources((prev) => { + const next = new Set(prev); + next.delete(uri); + return next; + }); + } }; const subscribeToResource = async (uri: string) => { @@ -1308,6 +1352,10 @@ const App = () => { Tasks + + + Apps + Ping @@ -1497,6 +1545,19 @@ const App = () => { error={errors.tasks} nextCursor={nextTaskCursor} /> + { + clearError("tools"); + listTools(); + }} + error={errors.tools} + mcpClient={mcpClient} + onNotification={(notification) => { + setNotifications((prev) => [...prev, notification]); + }} + /> { diff --git a/client/src/components/AppRenderer.tsx b/client/src/components/AppRenderer.tsx new file mode 100644 index 000000000..e0c259809 --- /dev/null +++ b/client/src/components/AppRenderer.tsx @@ -0,0 +1,128 @@ +import { useMemo, useState } from "react"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + Tool, + ContentBlock, + ServerNotification, + LoggingMessageNotificationParams, +} from "@modelcontextprotocol/sdk/types.js"; +import { + AppRenderer as McpUiAppRenderer, + type McpUiHostContext, + type RequestHandlerExtra, +} from "@mcp-ui/client"; +import { + type McpUiMessageRequest, + type McpUiMessageResult, +} from "@modelcontextprotocol/ext-apps/app-bridge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { useToast } from "@/lib/hooks/useToast"; + +interface AppRendererProps { + sandboxPath: string; + tool: Tool; + mcpClient: Client | null; + toolInput?: Record; + onNotification?: (notification: ServerNotification) => void; +} + +const AppRenderer = ({ + sandboxPath, + tool, + mcpClient, + toolInput, + onNotification, +}: AppRendererProps) => { + const [error, setError] = useState(null); + const { toast } = useToast(); + + const hostContext: McpUiHostContext = useMemo( + () => ({ + theme: document.documentElement.classList.contains("dark") + ? "dark" + : "light", + }), + [], + ); + + const handleOpenLink = async ({ url }: { url: string }) => { + let isError = true; + if (url.startsWith("https://") || url.startsWith("http://")) { + window.open(url, "_blank"); + isError = false; + } + return { isError }; + }; + + const handleMessage = async ( + params: McpUiMessageRequest["params"], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _extra: RequestHandlerExtra, + ): Promise => { + const message = params.content + .filter((block): block is ContentBlock & { type: "text" } => + Boolean(block.type === "text"), + ) + .map((block) => block.text) + .join("\n"); + + if (message) { + toast({ + description: message, + }); + } + + return {}; + }; + + const handleLoggingMessage = (params: LoggingMessageNotificationParams) => { + if (onNotification) { + onNotification({ + method: "notifications/message", + params, + } as ServerNotification); + } + }; + + if (!mcpClient) { + return ( + + + Waiting for MCP client... + + ); + } + + return ( +
+ {error && ( + + + {error} + + )} + +
+ setError(err.message)} + /> +
+
+ ); +}; + +export default AppRenderer; diff --git a/client/src/components/AppsTab.tsx b/client/src/components/AppsTab.tsx new file mode 100644 index 000000000..1943c4396 --- /dev/null +++ b/client/src/components/AppsTab.tsx @@ -0,0 +1,530 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { TabsContent } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + AlertCircle, + X, + Play, + ChevronRight, + Maximize2, + Minimize2, +} from "lucide-react"; +import { Tool, ServerNotification } 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"; +import ListPane from "./ListPane"; +import IconDisplay, { WithIcons } from "./IconDisplay"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { Input } from "@/components/ui/input"; +import DynamicJsonForm, { DynamicJsonFormRef } from "./DynamicJsonForm"; +import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils"; +import { + generateDefaultValue, + isPropertyRequired, + normalizeUnionType, + resolveRef, +} from "@/utils/schemaUtils"; + +interface AppsTabProps { + sandboxPath: string; + tools: Tool[]; + listTools: () => void; + error: string | null; + mcpClient: Client | null; + onNotification?: (notification: ServerNotification) => void; +} + +// Type guard to check if a tool has UI metadata +const hasUIMetadata = (tool: Tool): boolean => { + return !!getToolUiResourceUri(tool); +}; + +const AppsTab = ({ + sandboxPath, + tools, + listTools, + error, + mcpClient, + onNotification, +}: AppsTabProps) => { + const [appTools, setAppTools] = useState([]); + const [selectedTool, setSelectedTool] = useState(null); + const [params, setParams] = useState>({}); + const [isAppOpen, setIsAppOpen] = useState(false); + const [isMaximized, setIsMaximized] = useState(false); + const [hasValidationErrors, setHasValidationErrors] = useState(false); + const formRefs = useRef>({}); + + // Function to check if any form has validation errors + const checkValidationErrors = useCallback(() => { + const errors = Object.values(formRefs.current).some( + (ref) => ref && !ref.validateJson().isValid, + ); + setHasValidationErrors(errors); + return errors; + }, []); + + // Filter tools that have UI metadata + useEffect(() => { + const filtered = tools.filter(hasUIMetadata); + console.log("[AppsTab] Filtered app tools:", { + totalTools: tools.length, + appTools: filtered.length, + appToolNames: filtered.map((t) => t.name), + }); + setAppTools(filtered); + + // If current selected tool is no longer available, reset selection + if (selectedTool && !filtered.find((t) => t.name === selectedTool.name)) { + setSelectedTool(null); + setIsAppOpen(false); + } + }, [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)); + setHasValidationErrors(false); + formRefs.current = {}; + } else { + setParams({}); + setIsAppOpen(false); + } + }, [selectedTool]); + + const handleRefresh = useCallback(() => { + listTools(); + }, [listTools]); + + const handleCloseApp = useCallback(() => { + setIsAppOpen(false); + }, []); + + const handleOpenApp = useCallback(() => { + if (!checkValidationErrors()) { + setIsAppOpen(true); + } + }, [checkValidationErrors]); + + const handleSelectTool = useCallback((tool: Tool) => { + setSelectedTool(tool); + const hasFields = + tool.inputSchema.properties && + Object.keys(tool.inputSchema.properties).length > 0; + setIsAppOpen(!hasFields); + }, []); + + const handleDeselectTool = useCallback(() => { + setSelectedTool(null); + setIsAppOpen(false); + setIsMaximized(false); + }, []); + + return ( + +
+ {!isMaximized && ( + { + return ( +
+
+ +
+
+ {tool.name} + {tool.description && ( + + {tool.description} + + )} +
+ +
+ ); + }} + title="MCP Apps" + buttonText="Refresh Apps" + /> + )} + +
+
+
+
+ {selectedTool && ( + + )} +

+ {selectedTool ? selectedTool.name : "Select an app"} +

+
+
+ {selectedTool && isAppOpen && ( + + )} + {selectedTool && ( + + )} +
+
+
+ +
+ {error && ( + + + {error} + + )} + + {selectedTool ? ( + (() => { + const hasFields = + selectedTool.inputSchema.properties && + Object.keys(selectedTool.inputSchema.properties).length > 0; + + return ( +
+ {!isAppOpen ? ( +
+ {selectedTool.description && ( +

+ {selectedTool.description} +

+ )} + +
+

App Input

+ {Object.entries( + selectedTool.inputSchema.properties ?? [], + ).map(([key, value]) => { + // First resolve any $ref references + const resolvedValue = resolveRef( + value as JsonSchemaType, + selectedTool.inputSchema as JsonSchemaType, + ); + const prop = normalizeUnionType(resolvedValue); + const inputSchema = + selectedTool.inputSchema as JsonSchemaType; + const required = isPropertyRequired( + key, + inputSchema, + ); + + return ( +
+
+ + {prop.nullable ? ( +
+ + setParams({ + ...params, + [key]: checked + ? null + : prop.type === "array" + ? undefined + : prop.default !== null + ? prop.default + : prop.type === "boolean" + ? false + : prop.type === "string" + ? "" + : undefined, + }) + } + /> + +
+ ) : null} +
+ +
+ {prop.type === "boolean" ? ( +
+ + setParams({ + ...params, + [key]: checked, + }) + } + /> + +
+ ) : prop.type === "string" && prop.enum ? ( + + ) : prop.type === "string" ? ( +