| | import { getToolUiResourceUri, McpUiToolMetaSchema } from "@modelcontextprotocol/ext-apps/app-bridge"; |
| | import type { Tool } from "@modelcontextprotocol/sdk/types.js"; |
| | import { Component, type ErrorInfo, type ReactNode, StrictMode, Suspense, use, useEffect, useMemo, useRef, useState } from "react"; |
| | import { createRoot } from "react-dom/client"; |
| | import { callTool, connectToServer, hasAppHtml, initializeApp, loadSandboxProxy, log, newAppBridge, type ServerInfo, type ToolCallInfo, type ModelContext, type AppMessage } from "./implementation"; |
| | import styles from "./index.module.css"; |
| |
|
| | |
| | |
| | |
| | |
| | function isToolVisibleToModel(tool: { _meta?: Record<string, unknown> }): boolean { |
| | const result = McpUiToolMetaSchema.safeParse(tool._meta?.ui); |
| | if (!result.success) return true; |
| | const visibility = result.data.visibility; |
| | if (!visibility) return true; |
| | return visibility.includes("model"); |
| | } |
| |
|
| | |
| | function compareTools(a: Tool, b: Tool): number { |
| | const aHasUi = !!getToolUiResourceUri(a); |
| | const bHasUi = !!getToolUiResourceUri(b); |
| | if (aHasUi && !bHasUi) return -1; |
| | if (!aHasUi && bHasUi) return 1; |
| | return a.name.localeCompare(b.name); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | function getToolDefaults(tool: Tool | undefined): string { |
| | if (!tool?.inputSchema?.properties) return "{}"; |
| |
|
| | const defaults: Record<string, unknown> = {}; |
| | for (const [key, prop] of Object.entries(tool.inputSchema.properties)) { |
| | if (prop && typeof prop === "object" && "default" in prop) { |
| | defaults[key] = prop.default; |
| | } |
| | } |
| |
|
| | return Object.keys(defaults).length > 0 |
| | ? JSON.stringify(defaults, null, 2) |
| | : "{}"; |
| | } |
| |
|
| |
|
| | |
| | interface HostProps { |
| | serversPromise: Promise<ServerInfo[]>; |
| | } |
| |
|
| | type ToolCallEntry = ToolCallInfo & { id: number }; |
| | let nextToolCallId = 0; |
| |
|
| | function Host({ serversPromise }: HostProps) { |
| | const [toolCalls, setToolCalls] = useState<ToolCallEntry[]>([]); |
| | const [destroyingIds, setDestroyingIds] = useState<Set<number>>(new Set()); |
| |
|
| | const requestClose = (id: number) => { |
| | setDestroyingIds((s) => new Set(s).add(id)); |
| | }; |
| |
|
| | const completeClose = (id: number) => { |
| | setDestroyingIds((s) => { |
| | const next = new Set(s); |
| | next.delete(id); |
| | return next; |
| | }); |
| | setToolCalls((calls) => calls.filter((c) => c.id !== id)); |
| | }; |
| |
|
| | return ( |
| | <> |
| | {toolCalls.map((info) => ( |
| | <ToolCallInfoPanel |
| | key={info.id} |
| | toolCallInfo={info} |
| | isDestroying={destroyingIds.has(info.id)} |
| | onRequestClose={() => requestClose(info.id)} |
| | onCloseComplete={() => completeClose(info.id)} |
| | /> |
| | ))} |
| | <CallToolPanel |
| | serversPromise={serversPromise} |
| | addToolCall={(info) => setToolCalls([...toolCalls, { ...info, id: nextToolCallId++ }])} |
| | /> |
| | </> |
| | ); |
| | } |
| |
|
| |
|
| | |
| | interface CallToolPanelProps { |
| | serversPromise: Promise<ServerInfo[]>; |
| | addToolCall: (info: ToolCallInfo) => void; |
| | } |
| | function CallToolPanel({ serversPromise, addToolCall }: CallToolPanelProps) { |
| | const [selectedServer, setSelectedServer] = useState<ServerInfo | null>(null); |
| | const [selectedTool, setSelectedTool] = useState(""); |
| | const [inputJson, setInputJson] = useState("{}"); |
| |
|
| | |
| | const toolNames = selectedServer |
| | ? Array.from(selectedServer.tools.values()) |
| | .filter((tool) => isToolVisibleToModel(tool)) |
| | .sort(compareTools) |
| | .map((tool) => tool.name) |
| | : []; |
| |
|
| | const isValidJson = useMemo(() => { |
| | try { |
| | JSON.parse(inputJson); |
| | return true; |
| | } catch { |
| | return false; |
| | } |
| | }, [inputJson]); |
| |
|
| | const handleServerSelect = (server: ServerInfo) => { |
| | setSelectedServer(server); |
| | |
| | const visibleTools = Array.from(server.tools.values()) |
| | .filter((tool) => isToolVisibleToModel(tool)) |
| | .sort(compareTools); |
| | const firstTool = visibleTools[0]?.name ?? ""; |
| | setSelectedTool(firstTool); |
| | |
| | setInputJson(getToolDefaults(server.tools.get(firstTool))); |
| | }; |
| |
|
| | const handleToolSelect = (toolName: string) => { |
| | setSelectedTool(toolName); |
| | |
| | setInputJson(getToolDefaults(selectedServer?.tools.get(toolName))); |
| | }; |
| |
|
| | const handleSubmit = () => { |
| | if (!selectedServer) return; |
| | const toolCallInfo = callTool(selectedServer, selectedTool, JSON.parse(inputJson)); |
| | addToolCall(toolCallInfo); |
| | }; |
| |
|
| | return ( |
| | <div className={styles.callToolPanel}> |
| | <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}> |
| | <label> |
| | Server |
| | <Suspense fallback={<select disabled><option>Loading...</option></select>}> |
| | <ServerSelect serversPromise={serversPromise} onSelect={handleServerSelect} /> |
| | </Suspense> |
| | </label> |
| | <label> |
| | Tool |
| | <select |
| | className={styles.toolSelect} |
| | value={selectedTool} |
| | onChange={(e) => handleToolSelect(e.target.value)} |
| | > |
| | {selectedServer && toolNames.map((name) => ( |
| | <option key={name} value={name}>{name}</option> |
| | ))} |
| | </select> |
| | </label> |
| | <label> |
| | Input |
| | <textarea |
| | className={styles.toolInput} |
| | aria-invalid={!isValidJson} |
| | value={inputJson} |
| | onChange={(e) => setInputJson(e.target.value)} |
| | /> |
| | </label> |
| | <button type="submit" disabled={!selectedTool || !isValidJson}> |
| | Call Tool |
| | </button> |
| | </form> |
| | </div> |
| | ); |
| | } |
| |
|
| |
|
| | |
| | interface ServerSelectProps { |
| | serversPromise: Promise<ServerInfo[]>; |
| | onSelect: (server: ServerInfo) => void; |
| | } |
| | function ServerSelect({ serversPromise, onSelect }: ServerSelectProps) { |
| | const servers = use(serversPromise); |
| | const [selectedIndex, setSelectedIndex] = useState(0); |
| |
|
| | useEffect(() => { |
| | if (servers.length > selectedIndex) { |
| | onSelect(servers[selectedIndex]); |
| | } |
| | }, [servers]); |
| |
|
| | if (servers.length === 0) { |
| | return <select disabled><option>No servers configured</option></select>; |
| | } |
| |
|
| | return ( |
| | <select |
| | value={selectedIndex} |
| | onChange={(e) => { |
| | const newIndex = Number(e.target.value); |
| | setSelectedIndex(newIndex); |
| | onSelect(servers[newIndex]); |
| | }} |
| | > |
| | {servers.map((server, i) => ( |
| | <option key={i} value={i}>{server.name}</option> |
| | ))} |
| | </select> |
| | ); |
| | } |
| |
|
| |
|
| | interface ToolCallInfoPanelProps { |
| | toolCallInfo: ToolCallInfo; |
| | isDestroying?: boolean; |
| | onRequestClose?: () => void; |
| | onCloseComplete?: () => void; |
| | } |
| | function ToolCallInfoPanel({ toolCallInfo, isDestroying, onRequestClose, onCloseComplete }: ToolCallInfoPanelProps) { |
| | const isApp = hasAppHtml(toolCallInfo); |
| |
|
| | |
| | useEffect(() => { |
| | if (isDestroying && !isApp) { |
| | onCloseComplete?.(); |
| | } |
| | }, [isDestroying, isApp, onCloseComplete]); |
| |
|
| | return ( |
| | <div |
| | className={styles.toolCallInfoPanel} |
| | style={isDestroying ? { opacity: 0.5, pointerEvents: "none" } : undefined} |
| | > |
| | {/* For non-app tools, show input/output side by side */} |
| | {!isApp && ( |
| | <div className={styles.inputInfoPanel}> |
| | <h2> |
| | <span>{toolCallInfo.serverInfo.name}</span> |
| | <span className={styles.toolName}>{toolCallInfo.tool.name}</span> |
| | {onRequestClose && !isDestroying && ( |
| | <button |
| | className={styles.closeButton} |
| | onClick={onRequestClose} |
| | title="Close" |
| | > |
| | × |
| | </button> |
| | )} |
| | </h2> |
| | <JsonBlock value={toolCallInfo.input} /> |
| | </div> |
| | )} |
| | <div className={isApp ? styles.appOutputPanel : styles.outputInfoPanel}> |
| | {/* For apps, show header above the app: ServerName:tool_name */} |
| | {isApp && ( |
| | <div className={styles.appHeader}> |
| | <span>{toolCallInfo.serverInfo.name}:<span className={styles.toolName}>{toolCallInfo.tool.name}</span></span> |
| | {onRequestClose && !isDestroying && ( |
| | <button |
| | className={styles.closeButton} |
| | onClick={onRequestClose} |
| | title="Close" |
| | > |
| | × |
| | </button> |
| | )} |
| | </div> |
| | )} |
| | <ErrorBoundary> |
| | <Suspense fallback="Loading..."> |
| | { |
| | isApp |
| | ? <AppIFramePanel |
| | toolCallInfo={toolCallInfo} |
| | isDestroying={isDestroying} |
| | onTeardownComplete={onCloseComplete} |
| | /> |
| | : <ToolResultPanel toolCallInfo={toolCallInfo} /> |
| | } |
| | </Suspense> |
| | </ErrorBoundary> |
| | </div> |
| | </div> |
| | ); |
| | } |
| |
|
| |
|
| | function JsonBlock({ value }: { value: object }) { |
| | return ( |
| | <pre className={styles.jsonBlock}> |
| | <code>{JSON.stringify(value, null, 2)}</code> |
| | </pre> |
| | ); |
| | } |
| |
|
| |
|
| | interface CollapsiblePanelProps { |
| | icon: string; |
| | label: string; |
| | content: string; |
| | badge?: string; |
| | defaultExpanded?: boolean; |
| | } |
| | function CollapsiblePanel({ icon, label, content, badge, defaultExpanded = false }: CollapsiblePanelProps) { |
| | const [expanded, setExpanded] = useState(defaultExpanded); |
| |
|
| | return ( |
| | <div |
| | className={styles.collapsiblePanel} |
| | onClick={() => setExpanded(!expanded)} |
| | title={expanded ? "Click to collapse" : "Click to expand"} |
| | > |
| | <div className={styles.collapsibleHeader}> |
| | <span className={styles.collapsibleLabel}>{icon} {label}</span> |
| | <span className={styles.collapsibleSize}> |
| | {badge ?? `${content.length} chars`} |
| | </span> |
| | <span className={styles.collapsibleToggle}> |
| | {expanded ? "▼" : "▶"} |
| | </span> |
| | </div> |
| | {expanded ? ( |
| | <pre className={styles.collapsibleFull}>{content}</pre> |
| | ) : ( |
| | <div className={styles.collapsiblePreview}> |
| | {content.slice(0, 100)}{content.length > 100 ? "…" : ""} |
| | </div> |
| | )} |
| | </div> |
| | ); |
| | } |
| |
|
| |
|
| | interface AppIFramePanelProps { |
| | toolCallInfo: Required<ToolCallInfo>; |
| | isDestroying?: boolean; |
| | onTeardownComplete?: () => void; |
| | } |
| | function AppIFramePanel({ toolCallInfo, isDestroying, onTeardownComplete }: AppIFramePanelProps) { |
| | const iframeRef = useRef<HTMLIFrameElement | null>(null); |
| | const appBridgeRef = useRef<ReturnType<typeof newAppBridge> | null>(null); |
| | const [modelContext, setModelContext] = useState<ModelContext | null>(null); |
| | const [toolResult, setToolResult] = useState<object | null>(null); |
| | const [messages, setMessages] = useState<AppMessage[]>([]); |
| |
|
| | useEffect(() => { |
| | const iframe = iframeRef.current!; |
| |
|
| | |
| | |
| | toolCallInfo.appResourcePromise.then(({ csp, permissions }) => { |
| | loadSandboxProxy(iframe, csp, permissions).then((firstTime) => { |
| | |
| | |
| | |
| | |
| | if (firstTime) { |
| | const appBridge = newAppBridge(toolCallInfo.serverInfo, iframe, { |
| | onContextUpdate: setModelContext, |
| | onMessage: (msg) => setMessages((prev) => [...prev, msg]), |
| | }); |
| | appBridgeRef.current = appBridge; |
| | initializeApp(iframe, appBridge, toolCallInfo); |
| | } |
| | }); |
| | }); |
| |
|
| | |
| | toolCallInfo.resultPromise.then(setToolResult).catch(() => {}); |
| | }, [toolCallInfo]); |
| |
|
| | |
| | |
| | |
| | useEffect(() => { |
| | if (!isDestroying) return; |
| |
|
| | if (!appBridgeRef.current) { |
| | |
| | onTeardownComplete?.(); |
| | return; |
| | } |
| |
|
| | log.info("Sending teardown notification to MCP App"); |
| | appBridgeRef.current.teardownResource({}) |
| | .catch((err) => { |
| | log.warn("Teardown request failed (app may have already closed):", err); |
| | }) |
| | .finally(() => { |
| | onTeardownComplete?.(); |
| | }); |
| | }, [isDestroying, onTeardownComplete]); |
| |
|
| | |
| | const formatContentBlock = (c: { type: string; [key: string]: unknown }) => { |
| | switch (c.type) { |
| | case "text": |
| | return (c as { type: "text"; text: string }).text; |
| | case "image": |
| | return `<image: ${(c as { mimeType?: string }).mimeType ?? "unknown"}>`; |
| | case "audio": |
| | return `<audio: ${(c as { mimeType?: string }).mimeType ?? "unknown"}>`; |
| | case "resource": |
| | return `<resource: ${(c as { resource?: { uri?: string } }).resource?.uri ?? "unknown"}>`; |
| | default: |
| | return `<${c.type}>`; |
| | } |
| | }; |
| |
|
| | |
| | const contextText = modelContext?.content?.map(formatContentBlock).join("\n") ?? ""; |
| | const contextJson = modelContext?.structuredContent |
| | ? JSON.stringify(modelContext.structuredContent, null, 2) |
| | : ""; |
| | const fullContext = [contextText, contextJson].filter(Boolean).join("\n\n"); |
| |
|
| | const inputJson = JSON.stringify(toolCallInfo.input, null, 2); |
| | const resultJson = toolResult ? JSON.stringify(toolResult, null, 2) : null; |
| |
|
| | |
| | const formatMessage = (m: AppMessage) => { |
| | const content = m.content.map(formatContentBlock).join("\n"); |
| | return `[${m.role}] ${content}`; |
| | }; |
| | const messagesText = messages.map(formatMessage).join("\n\n"); |
| |
|
| | return ( |
| | <div className={styles.appIframePanel}> |
| | <CollapsiblePanel icon="📥" label="Tool Input" content={inputJson} /> |
| | <iframe ref={iframeRef} /> |
| | {resultJson && ( |
| | <CollapsiblePanel icon="📤" label="Tool Result" content={resultJson} /> |
| | )} |
| | {messages.length > 0 && ( |
| | <CollapsiblePanel |
| | icon="💬" |
| | label="Messages" |
| | content={messagesText} |
| | badge={`${messages.length} message${messages.length > 1 ? "s" : ""}`} |
| | /> |
| | )} |
| | {modelContext && ( |
| | <CollapsiblePanel icon="📋" label="Model Context" content={fullContext} /> |
| | )} |
| | </div> |
| | ); |
| | } |
| |
|
| |
|
| | interface ToolResultPanelProps { |
| | toolCallInfo: ToolCallInfo; |
| | } |
| | function ToolResultPanel({ toolCallInfo }: ToolResultPanelProps) { |
| | const result = use(toolCallInfo.resultPromise); |
| | return <JsonBlock value={result} />; |
| | } |
| |
|
| |
|
| | interface ErrorBoundaryProps { |
| | children: ReactNode; |
| | } |
| | interface ErrorBoundaryState { |
| | hasError: boolean; |
| | error: unknown; |
| | } |
| | class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { |
| | state: ErrorBoundaryState = { hasError: false, error: undefined }; |
| |
|
| | |
| | |
| | static getDerivedStateFromError(error: unknown): ErrorBoundaryState { |
| | return { hasError: true, error }; |
| | } |
| |
|
| | |
| | componentDidCatch(error: unknown, errorInfo: ErrorInfo): void { |
| | log.error("Caught:", error, errorInfo.componentStack); |
| | } |
| |
|
| | render(): ReactNode { |
| | if (this.state.hasError) { |
| | const { error } = this.state; |
| | const message = error instanceof Error ? error.message : String(error); |
| | return <div className={styles.error}><strong>ERROR:</strong> {message}</div>; |
| | } |
| | return this.props.children; |
| | } |
| | } |
| |
|
| |
|
| | async function connectToAllServers(): Promise<ServerInfo[]> { |
| | const serverUrlsResponse = await fetch("/api/servers"); |
| | const serverUrls = (await serverUrlsResponse.json()) as string[]; |
| |
|
| | |
| | const results = await Promise.allSettled( |
| | serverUrls.map((url) => connectToServer(new URL(url))) |
| | ); |
| |
|
| | const servers: ServerInfo[] = []; |
| | for (let i = 0; i < results.length; i++) { |
| | const result = results[i]; |
| | if (result.status === "fulfilled") { |
| | servers.push(result.value); |
| | } else { |
| | console.warn(`[HOST] Failed to connect to ${serverUrls[i]}:`, result.reason); |
| | } |
| | } |
| |
|
| | if (servers.length === 0 && serverUrls.length > 0) { |
| | throw new Error(`Failed to connect to any servers (${serverUrls.length} attempted)`); |
| | } |
| |
|
| | return servers; |
| | } |
| |
|
| | createRoot(document.getElementById("root")!).render( |
| | <StrictMode> |
| | <ErrorBoundary> |
| | <Host serversPromise={connectToAllServers()} /> |
| | </ErrorBoundary> |
| | </StrictMode>, |
| | ); |
| |
|