diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index faddf8df8..d8734a82f 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -833,7 +833,6 @@ export default function App() { { + setSelectedServer(serverName); + handleNavigate("app-builder"); + }} workspaces={workspaces} activeWorkspaceId={activeWorkspaceId} isLoadingWorkspaces={isLoadingRemoteWorkspaces} diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 5ccb51265..3ab546930 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -3,6 +3,7 @@ import { Card } from "./ui/card"; import { Button } from "./ui/button"; import { Plus, FileText } from "lucide-react"; import { ServerWithName, type ServerUpdateResult } from "@/hooks/use-app-state"; +import { useUiAppServers } from "@/hooks/use-ui-app-servers"; import { ServerConnectionCard } from "./connection/ServerConnectionCard"; import { AddServerModal } from "./connection/AddServerModal"; import { @@ -86,6 +87,8 @@ function SortableServerCard({ onDisconnect, onReconnect, onRemove, + onOpenAppBuilder, + isUiCapabilityResolved, hostedServerId, onOpenDetailModal, }: { @@ -99,6 +102,8 @@ function SortableServerCard({ opts?: { forceOAuthFlow?: boolean }, ) => Promise; onRemove: (name: string) => void; + onOpenAppBuilder?: (serverName: string) => void; + isUiCapabilityResolved?: boolean; hostedServerId?: string; onOpenDetailModal?: ( server: ServerWithName, @@ -127,6 +132,8 @@ function SortableServerCard({ onDisconnect={onDisconnect} onReconnect={onReconnect} onRemove={onRemove} + onOpenAppBuilder={onOpenAppBuilder} + isUiCapabilityResolved={isUiCapabilityResolved} hostedServerId={hostedServerId} onOpenDetailModal={onOpenDetailModal} /> @@ -148,6 +155,7 @@ interface ServersTabProps { skipAutoConnect?: boolean, ) => Promise; onRemove: (serverName: string) => void; + onOpenAppBuilder?: (serverName: string) => void; workspaces: Record; activeWorkspaceId: string; isLoadingWorkspaces?: boolean; @@ -160,6 +168,7 @@ export function ServersTab({ onReconnect, onUpdate, onRemove, + onOpenAppBuilder, workspaces, activeWorkspaceId, isLoadingWorkspaces, @@ -185,6 +194,16 @@ export function ServersTab({ sessionKey: 0, serverSnapshot: null, }); + const { appServerNames, resolvedServerNames } = + useUiAppServers(workspaceServers); + const appServerNameSet = useMemo( + () => new Set(appServerNames), + [appServerNames], + ); + const resolvedServerNameSet = useMemo( + () => new Set(resolvedServerNames), + [resolvedServerNames], + ); // --- Self-contained local ordering (localStorage only, never synced to Convex) --- const allNames = useMemo( @@ -510,6 +529,12 @@ export function ServersTab({ onDisconnect={onDisconnect} onReconnect={onReconnect} onRemove={onRemove} + onOpenAppBuilder={ + appServerNameSet.has(name) + ? onOpenAppBuilder + : undefined + } + isUiCapabilityResolved={resolvedServerNameSet.has(name)} hostedServerId={sharedWorkspaceServersRecord[name]?._id} onOpenDetailModal={handleOpenDetailModal} /> @@ -528,6 +553,14 @@ export function ServersTab({ onDisconnect={onDisconnect} onReconnect={onReconnect} onRemove={onRemove} + onOpenAppBuilder={ + appServerNameSet.has(activeServer.name) + ? onOpenAppBuilder + : undefined + } + isUiCapabilityResolved={resolvedServerNameSet.has( + activeServer.name, + )} hostedServerId={ sharedWorkspaceServersRecord[activeId!]?._id } diff --git a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx index 146ca33c9..e3c98b682 100644 --- a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx +++ b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx @@ -21,6 +21,7 @@ import { Edit, ExternalLink, Cable, + Rocket, Trash2, AlertCircle, Share2, @@ -75,6 +76,8 @@ interface ServerConnectionCardProps { onRemove?: (serverName: string) => void; serverTunnelUrl?: string | null; hostedServerId?: string; + onOpenAppBuilder?: (serverName: string) => void; + isUiCapabilityResolved?: boolean; onOpenDetailModal?: ( server: ServerWithName, defaultTab: ServerDetailTab, @@ -89,6 +92,8 @@ export function ServerConnectionCard({ onRemove, serverTunnelUrl, hostedServerId, + onOpenAppBuilder, + isUiCapabilityResolved = true, onOpenDetailModal, }: ServerConnectionCardProps) { const posthog = usePostHog(); @@ -121,8 +126,11 @@ export function ServerConnectionCard({ const isConnected = server.connectionStatus === "connected"; const isTunnelEnabled = !HOSTED_MODE; const canManageTunnels = isAuthenticated; - const showTunnelActions = isConnected && isTunnelEnabled; + const showTunnelActions = + isConnected && isUiCapabilityResolved && isTunnelEnabled; const hasTunnel = Boolean(tunnelUrl); + const showAppBuilderAction = + isConnected && isUiCapabilityResolved && onOpenAppBuilder != null; const hasError = server.connectionStatus === "failed" && Boolean(server.lastError); const isHostedHttpReconnectBlocked = isHostedInsecureHttpServer(server); @@ -589,6 +597,15 @@ export function ServerConnectionCard({ className="flex items-center gap-2" onClick={(e) => e.stopPropagation()} > + {showAppBuilderAction && ( + + )} {canShareServer && ( - )} -
-

- {guideBubble.message} -

- {guideBubble.subMessage && ( -

- {guideBubble.subMessage} -

- )} -
- - , - document.body, - )} - - ); -} diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-ui-app-servers.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-ui-app-servers.test.tsx new file mode 100644 index 000000000..817f6d332 --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-ui-app-servers.test.tsx @@ -0,0 +1,133 @@ +import { renderHook, waitFor, act } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ServerWithName } from "@/hooks/use-app-state"; +import { useUiAppServers } from "../use-ui-app-servers"; + +const { mockListTools, mockIsMCPApp } = vi.hoisted(() => ({ + mockListTools: vi.fn(), + mockIsMCPApp: vi.fn(() => false), +})); + +vi.mock("@/lib/apis/mcp-tools-api", () => ({ + listTools: mockListTools, +})); + +vi.mock("@/lib/mcp-ui/mcp-apps-utils", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isMCPApp: mockIsMCPApp, + }; +}); + +function createServer( + name: string, + overrides: Partial = {}, +): ServerWithName { + return { + name, + lastConnectionTime: new Date(), + connectionStatus: "connected", + enabled: true, + retryCount: 0, + useOAuth: false, + config: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-test"], + }, + ...overrides, + }; +} + +describe("useUiAppServers", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("marks a connected server as resolved when tools metadata has no UI", async () => { + mockListTools.mockResolvedValue({ + tools: [], + toolsMetadata: {}, + }); + + const servers = { + "test-server": createServer("test-server"), + }; + + const { result } = renderHook(() => useUiAppServers(servers)); + + await waitFor(() => { + expect(result.current.resolvedServerNames).toEqual(["test-server"]); + }); + + expect(result.current.appServerNames).toEqual([]); + expect(result.current.hasAppServer).toBe(false); + }); + + it("marks a connected server as resolved when the UI capability check fails", async () => { + mockListTools.mockRejectedValue(new Error("tools/list failed")); + + const servers = { + "test-server": createServer("test-server"), + }; + + const { result } = renderHook(() => useUiAppServers(servers)); + + await waitFor(() => { + expect(result.current.resolvedServerNames).toEqual(["test-server"]); + }); + + expect(result.current.appServerNames).toEqual([]); + expect(result.current.hasAppServer).toBe(false); + }); + + it("identifies a server as a UI app when isMCPApp returns true", async () => { + mockListTools.mockResolvedValue({ + tools: [], + toolsMetadata: { "render-ui": { "ui.resourceUri": "ui://app" } }, + }); + mockIsMCPApp.mockReturnValue(true); + + const servers = { + "test-server": createServer("test-server"), + }; + + const { result } = renderHook(() => useUiAppServers(servers)); + + await waitFor(() => { + expect(result.current.resolvedServerNames).toEqual(["test-server"]); + }); + + expect(result.current.appServerNames).toEqual(["test-server"]); + expect(result.current.hasAppServer).toBe(true); + + mockIsMCPApp.mockReturnValue(false); + }); + + it("marks a server as resolved after 5s timeout when listTools hangs", async () => { + vi.useFakeTimers(); + + // listTools never resolves + mockListTools.mockReturnValue(new Promise(() => {})); + + const servers = { + "test-server": createServer("test-server"), + }; + + const { result } = renderHook(() => useUiAppServers(servers)); + + // Not resolved yet + expect(result.current.resolvedServerNames).toEqual([]); + + // Advance past the 5s timeout + await act(async () => { + vi.advanceTimersByTime(5_000); + }); + + expect(result.current.resolvedServerNames).toEqual(["test-server"]); + expect(result.current.appServerNames).toEqual([]); + + vi.useRealTimers(); + }); +}); diff --git a/mcpjam-inspector/client/src/hooks/use-ui-app-servers.ts b/mcpjam-inspector/client/src/hooks/use-ui-app-servers.ts new file mode 100644 index 000000000..dd70d550a --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/use-ui-app-servers.ts @@ -0,0 +1,153 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { + listTools, + type ListToolsResultWithMetadata, +} from "@/lib/apis/mcp-tools-api"; +import { + isMCPApp, + isOpenAIApp, + isOpenAIAppAndMCPApp, +} from "@/lib/mcp-ui/mcp-apps-utils"; +import type { ServerWithName } from "./use-app-state"; + +const RESOLUTION_TIMEOUT_MS = 5_000; + +export function useUiAppServers(servers: Record) { + const [toolsDataMap, setToolsDataMap] = useState< + Record + >({}); + const [timedOutNames, setTimedOutNames] = useState>(new Set()); + const timeoutRefs = useRef>>( + new Map(), + ); + + const connectedServerNames = useMemo( + () => + Object.entries(servers) + .filter(([, server]) => server.connectionStatus === "connected") + .map(([name]) => name), + [servers], + ); + + const connectedServerNamesKey = connectedServerNames.join(","); + + useEffect(() => { + let cancelled = false; + + // Clear stale timeouts and timed-out names on server list change + for (const [name, timer] of timeoutRefs.current) { + if (!connectedServerNames.includes(name)) { + clearTimeout(timer); + timeoutRefs.current.delete(name); + } + } + setTimedOutNames((prev) => { + const next = new Set(); + for (const name of prev) { + if (connectedServerNames.includes(name)) next.add(name); + } + return next.size === prev.size ? prev : next; + }); + + const fetchToolsData = async () => { + if (connectedServerNames.length === 0) { + if (!cancelled) { + setToolsDataMap({}); + } + return; + } + + if (!cancelled) { + setToolsDataMap((prev) => + Object.fromEntries( + Object.entries(prev).filter(([serverName]) => + connectedServerNames.includes(serverName), + ), + ), + ); + } + + // Start per-server timeouts + for (const serverName of connectedServerNames) { + if (!timeoutRefs.current.has(serverName)) { + const timer = setTimeout(() => { + timeoutRefs.current.delete(serverName); + if (!cancelled) { + setTimedOutNames((prev) => { + if (prev.has(serverName)) return prev; + return new Set([...prev, serverName]); + }); + } + }, RESOLUTION_TIMEOUT_MS); + timeoutRefs.current.set(serverName, timer); + } + } + + await Promise.all( + connectedServerNames.map(async (serverName) => { + let result: ListToolsResultWithMetadata | null = null; + try { + result = await listTools({ + serverId: serverName, + }); + } catch { + result = null; + } + + if (!cancelled) { + // Clear timeout since we resolved + const timer = timeoutRefs.current.get(serverName); + if (timer) { + clearTimeout(timer); + timeoutRefs.current.delete(serverName); + } + setToolsDataMap((prev) => ({ + ...prev, + [serverName]: result, + })); + } + }), + ); + }; + + void fetchToolsData(); + + return () => { + cancelled = true; + for (const timer of timeoutRefs.current.values()) { + clearTimeout(timer); + } + timeoutRefs.current.clear(); + }; + }, [connectedServerNamesKey, connectedServerNames]); + + const appServerNames = useMemo( + () => + connectedServerNames.filter((serverName) => { + const toolsData = toolsDataMap[serverName]; + return ( + !!toolsData && + (isMCPApp(toolsData) || + isOpenAIApp(toolsData) || + isOpenAIAppAndMCPApp(toolsData)) + ); + }), + [connectedServerNames, toolsDataMap], + ); + + const resolvedServerNames = useMemo( + () => + connectedServerNames.filter( + (serverName) => + Object.prototype.hasOwnProperty.call(toolsDataMap, serverName) || + timedOutNames.has(serverName), + ), + [connectedServerNames, toolsDataMap, timedOutNames], + ); + + return { + appServerNames, + hasAppServer: appServerNames.length > 0, + resolvedServerNames, + }; +}