From e2ac4facf8a9213b75ff8d6969353cb8957c5892 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Wed, 18 Mar 2026 22:00:22 -0700 Subject: [PATCH 01/21] Support registry tab and servers V1 --- mcpjam-inspector/client/src/App.tsx | 14 + .../client/src/components/RegistryTab.tsx | 373 ++++++++++++++ .../client/src/components/ServersTab.tsx | 82 +++- .../components/__tests__/RegistryTab.test.tsx | 463 ++++++++++++++++++ .../client/src/components/mcp-sidebar.tsx | 6 + .../client/src/hooks/useRegistryServers.ts | 285 +++++++++++ .../lib/__tests__/hosted-navigation.test.ts | 4 +- .../lib/__tests__/hosted-tab-policy.test.ts | 3 +- .../client/src/lib/hosted-tab-policy.ts | 2 +- .../client/src/lib/oauth/mcp-oauth.ts | 85 +++- mcpjam-inspector/shared/types.ts | 2 + 11 files changed, 1306 insertions(+), 13 deletions(-) create mode 100644 mcpjam-inspector/client/src/components/RegistryTab.tsx create mode 100644 mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx create mode 100644 mcpjam-inspector/client/src/hooks/useRegistryServers.ts diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index faddf8df8..860e17686 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -27,6 +27,7 @@ import { AppBuilderTab } from "./components/ui-playground/AppBuilderTab"; import { ProfileTab } from "./components/ProfileTab"; import { OrganizationsTab } from "./components/OrganizationsTab"; import { SupportTab } from "./components/SupportTab"; +import { RegistryTab } from "./components/RegistryTab"; import OAuthDebugCallback from "./components/oauth/OAuthDebugCallback"; import { MCPSidebar } from "./components/mcp-sidebar"; import { SidebarInset, SidebarProvider } from "./components/ui/sidebar"; @@ -886,6 +887,19 @@ export default function App() { workspaces={workspaces} activeWorkspaceId={activeWorkspaceId} isLoadingWorkspaces={isLoadingRemoteWorkspaces} + onWorkspaceShared={handleWorkspaceShared} + onLeaveWorkspace={() => handleLeaveWorkspace(activeWorkspaceId)} + onNavigateToRegistry={() => handleNavigate("registry")} + /> + )} + {activeTab === "registry" && ( + )} {activeTab === "tools" && ( diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx new file mode 100644 index 000000000..bca82e062 --- /dev/null +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -0,0 +1,373 @@ +import { useState, useMemo, useEffect } from "react"; +import { + Package, + KeyRound, + ShieldOff, + CheckCircle2, + Loader2, + MoreVertical, + Unplug, +} from "lucide-react"; +import { Card } from "./ui/card"; +import { Button } from "./ui/button"; +import { Badge } from "./ui/badge"; +import { Skeleton } from "./ui/skeleton"; +import { EmptyState } from "./ui/empty-state"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { + useRegistryServers, + type EnrichedRegistryServer, + type RegistryConnectionStatus, +} from "@/hooks/useRegistryServers"; +import type { ServerFormData } from "@/shared/types.js"; +import type { ServerWithName } from "@/hooks/use-app-state"; + +interface RegistryTabProps { + workspaceId: string | null; + isAuthenticated: boolean; + onConnect: (formData: ServerFormData) => void; + onDisconnect?: (serverName: string) => void; + onNavigate?: (tab: string) => void; + servers?: Record; +} + +export function RegistryTab({ + workspaceId, + isAuthenticated, + onConnect, + onDisconnect, + onNavigate, + servers, +}: RegistryTabProps) { + // isAuthenticated is passed through to the hook for Convex mutation gating, + // but the registry is always browsable without auth. + const [selectedCategory, setSelectedCategory] = useState(null); + const [connectingIds, setConnectingIds] = useState>(new Set()); + + const { registryServers, categories, isLoading, connect, disconnect } = + useRegistryServers({ + workspaceId, + isAuthenticated, + liveServers: servers, + onConnect, + onDisconnect, + }); + + // Auto-redirect to App Builder when a pending server becomes connected. + // We persist in localStorage to survive OAuth redirects (page remounts). + useEffect(() => { + if (!onNavigate) return; + const pending = localStorage.getItem("registry-pending-redirect"); + if (!pending) return; + const liveServer = servers?.[pending]; + if (liveServer?.connectionStatus === "connected") { + localStorage.removeItem("registry-pending-redirect"); + onNavigate("app-builder"); + } + }, [servers, onNavigate]); + + const filteredServers = useMemo(() => { + if (!selectedCategory) return registryServers; + return registryServers.filter( + (s: EnrichedRegistryServer) => s.category === selectedCategory, + ); + }, [registryServers, selectedCategory]); + + const handleConnect = async (server: EnrichedRegistryServer) => { + setConnectingIds((prev) => new Set(prev).add(server._id)); + localStorage.setItem("registry-pending-redirect", server.displayName); + try { + await connect(server); + } finally { + setConnectingIds((prev) => { + const next = new Set(prev); + next.delete(server._id); + return next; + }); + } + }; + + const handleDisconnect = async (server: EnrichedRegistryServer) => { + const pending = localStorage.getItem("registry-pending-redirect"); + if (pending === server.displayName) { + localStorage.removeItem("registry-pending-redirect"); + } + await disconnect(server); + }; + + if (isLoading) { + return ; + } + + if (registryServers.length === 0) { + return ( + + ); + } + + return ( +
+
+ {/* Header */} +
+

Registry

+

+ Pre-configured MCP servers you can connect with one click. +

+
+ + {/* Category filter pills */} + {categories.length > 1 && ( +
+ + {categories.map((cat: string) => ( + + ))} +
+ )} + + {/* Server cards grid */} +
+ {filteredServers.map((server: EnrichedRegistryServer) => ( + handleConnect(server)} + onDisconnect={() => handleDisconnect(server)} + /> + ))} +
+
+
+ ); +} + +function RegistryServerCard({ + server, + isConnecting, + onConnect, + onDisconnect, +}: { + server: EnrichedRegistryServer; + isConnecting: boolean; + onConnect: () => void; + onDisconnect: () => void; +}) { + const effectiveStatus: RegistryConnectionStatus = isConnecting + ? "connecting" + : server.connectionStatus; + const isConnectedOrAdded = + effectiveStatus === "connected" || effectiveStatus === "added"; + + return ( + + {/* Top row: icon + name + auth pill + action (top-right) */} +
+ {server.iconUrl ? ( + {server.displayName} + ) : ( +
+ +
+ )} +
+
+

+ {server.displayName} +

+ + + {server.category} + +
+
+ + {server.publisher} + + {server.publisher === "MCPJam" && ( + + + + + )} +
+
+ {/* Top-right action */} +
+ +
+
+ + {/* Description */} +

+ {server.description} +

+
+ ); +} + +function AuthBadge({ useOAuth }: { useOAuth?: boolean }) { + if (useOAuth) { + return ( + + + OAuth + + ); + } + return ( + + + No auth + + ); +} + +function TopRightAction({ + status, + onConnect, + onDisconnect, +}: { + status: RegistryConnectionStatus; + onConnect: () => void; + onDisconnect: () => void; +}) { + switch (status) { + case "connected": + return ( +
+ + +
+ ); + case "connecting": + return ( + + ); + case "added": + return ( +
+ + +
+ ); + default: + return ( + + ); + } +} + +function OverflowMenu({ + onDisconnect, + label, +}: { + onDisconnect: () => void; + label: string; +}) { + return ( + + + + + + + + {label} + + + + ); +} + +function LoadingSkeleton() { + return ( +
+
+ + +
+
+ + + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 5ccb51265..8b42195b7 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; -import { Plus, FileText } from "lucide-react"; +import { Plus, FileText, Package, ArrowRight } from "lucide-react"; import { ServerWithName, type ServerUpdateResult } from "@/hooks/use-app-state"; import { ServerConnectionCard } from "./connection/ServerConnectionCard"; import { AddServerModal } from "./connection/AddServerModal"; @@ -14,6 +14,8 @@ import { JsonImportModal } from "./connection/JsonImportModal"; import { ServerFormData } from "@/shared/types.js"; import { MCPIcon } from "./ui/mcp-icon"; import { usePostHog } from "posthog-js/react"; +import { useQuery } from "convex/react"; +import type { RegistryServer } from "@/hooks/useRegistryServers"; import { detectEnvironment, detectPlatform } from "@/lib/PosthogUtils"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; import { @@ -151,6 +153,9 @@ interface ServersTabProps { workspaces: Record; activeWorkspaceId: string; isLoadingWorkspaces?: boolean; + onWorkspaceShared?: (sharedWorkspaceId: string) => void; + onLeaveWorkspace?: () => void; + onNavigateToRegistry?: () => void; } export function ServersTab({ @@ -163,9 +168,23 @@ export function ServersTab({ workspaces, activeWorkspaceId, isLoadingWorkspaces, + onWorkspaceShared, + onLeaveWorkspace, + onNavigateToRegistry, }: ServersTabProps) { const posthog = usePostHog(); const { isAuthenticated } = useConvexAuth(); + + // Fetch featured registry servers for the quick-connect section + const registryServers = useQuery( + "registryServers:listRegistryServers" as any, + isAuthenticated ? ({} as any) : "skip", + ) as RegistryServer[] | undefined; + const featuredRegistryServers = useMemo(() => { + if (!registryServers) return []; + const featured = registryServers.filter((s) => s.featured); + return (featured.length > 0 ? featured : registryServers).slice(0, 4); + }, [registryServers]); const { isVisible: isJsonRpcPanelVisible, toggle: toggleJsonRpcPanel } = useJsonRpcPanelVisibility(); const [isAddingServer, setIsAddingServer] = useState(false); @@ -571,6 +590,67 @@ export function ServersTab({ + {/* Quick Connect from Registry */} + {isAuthenticated && featuredRegistryServers.length > 0 && ( +
+
+

+ Quick Connect +

+ {onNavigateToRegistry && ( + + )} +
+
+ {featuredRegistryServers.map((server) => ( + + onConnect({ + name: server.displayName, + type: "http", + url: server.transport.url, + useOAuth: server.transport.useOAuth, + oauthScopes: server.transport.oauthScopes, + clientId: server.transport.clientId, + registryServerId: server._id, + }) + } + > + {server.iconUrl ? ( + {server.displayName} + ) : ( +
+ +
+ )} +
+

+ {server.displayName} +

+

+ {server.publisher} +

+
+
+ ))} +
+
+ )} + {/* Empty State */}
diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx new file mode 100644 index 000000000..77e31929a --- /dev/null +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -0,0 +1,463 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { RegistryTab } from "../RegistryTab"; +import type { EnrichedRegistryServer } from "@/hooks/useRegistryServers"; + +// Mock the useRegistryServers hook +const mockConnect = vi.fn(); +const mockDisconnect = vi.fn(); +let mockHookReturn: { + registryServers: EnrichedRegistryServer[]; + categories: string[]; + isLoading: boolean; + connect: typeof mockConnect; + disconnect: typeof mockDisconnect; +}; + +vi.mock("@/hooks/useRegistryServers", () => ({ + useRegistryServers: () => mockHookReturn, +})); + +// Mock dropdown menu to simplify testing +vi.mock("../ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode; + onClick?: () => void; + }) => ( + + ), +})); + +function createMockServer( + overrides: Partial = {}, +): EnrichedRegistryServer { + return { + _id: "server_1", + slug: "test-server", + displayName: "Test Server", + description: "A test MCP server for unit tests.", + publisher: "TestCo", + category: "Productivity", + transport: { type: "http", url: "https://mcp.test.com/sse" }, + approved: true, + createdAt: Date.now(), + updatedAt: Date.now(), + connectionStatus: "not_connected", + ...overrides, + }; +} + +describe("RegistryTab", () => { + const defaultProps = { + workspaceId: "ws_123", + isAuthenticated: true, + onConnect: vi.fn(), + onDisconnect: vi.fn(), + onNavigate: vi.fn(), + servers: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockConnect.mockResolvedValue(undefined); + mockDisconnect.mockResolvedValue(undefined); + mockHookReturn = { + registryServers: [], + categories: [], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + }); + + describe("visibility without authentication", () => { + it("renders registry servers when not authenticated", () => { + const server = createMockServer(); + mockHookReturn = { + registryServers: [server], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("Registry")).toBeInTheDocument(); + expect(screen.getByText("Test Server")).toBeInTheDocument(); + expect(screen.getByText("TestCo")).toBeInTheDocument(); + expect(screen.getByText("Connect")).toBeInTheDocument(); + }); + + it("shows header and description when not authenticated", () => { + mockHookReturn = { + registryServers: [createMockServer()], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("Registry")).toBeInTheDocument(); + expect( + screen.getByText( + "Pre-configured MCP servers you can connect with one click.", + ), + ).toBeInTheDocument(); + }); + }); + + describe("loading state", () => { + it("shows loading skeleton when data is loading", () => { + mockHookReturn = { + registryServers: [], + categories: [], + isLoading: true, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + const { container } = render(); + + const skeletons = container.querySelectorAll("[data-slot='skeleton']"); + expect(skeletons.length).toBeGreaterThan(0); + }); + }); + + describe("empty state", () => { + it("shows empty state when no servers are available", () => { + render(); + + expect(screen.getByText("No servers available")).toBeInTheDocument(); + }); + }); + + describe("auth badges", () => { + it("shows OAuth badge with key icon for OAuth servers", () => { + mockHookReturn = { + registryServers: [ + createMockServer({ + transport: { + type: "http", + url: "https://mcp.test.com/sse", + useOAuth: true, + }, + }), + ], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("OAuth")).toBeInTheDocument(); + }); + + it("shows No auth badge for servers without OAuth", () => { + mockHookReturn = { + registryServers: [createMockServer()], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("No auth")).toBeInTheDocument(); + }); + }); + + describe("server cards", () => { + it("renders server cards with correct information", () => { + const server = createMockServer({ + displayName: "Linear", + description: "Manage Linear issues and projects.", + publisher: "MCPJam", + category: "Project Management", + transport: { + type: "http", + url: "https://mcp.linear.app/sse", + useOAuth: true, + }, + }); + mockHookReturn = { + registryServers: [server], + categories: ["Project Management"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("Linear")).toBeInTheDocument(); + expect( + screen.getByText("Manage Linear issues and projects."), + ).toBeInTheDocument(); + expect(screen.getByText("MCPJam")).toBeInTheDocument(); + expect(screen.getByText("Project Management")).toBeInTheDocument(); + }); + + it("does not show raw URL by default", () => { + mockHookReturn = { + registryServers: [createMockServer()], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect( + screen.queryByText("https://mcp.test.com/sse"), + ).not.toBeInTheDocument(); + }); + + it("shows Connect button for not_connected servers", () => { + mockHookReturn = { + registryServers: [createMockServer()], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("Connect")).toBeInTheDocument(); + }); + + it("shows Connected badge for connected servers", () => { + mockHookReturn = { + registryServers: [ + createMockServer({ connectionStatus: "connected" }), + ], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("Connected")).toBeInTheDocument(); + }); + + it("shows Added badge for servers added but not live", () => { + mockHookReturn = { + registryServers: [createMockServer({ connectionStatus: "added" })], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("Added")).toBeInTheDocument(); + }); + }); + + describe("category filtering", () => { + it("shows category filter pills when multiple categories exist", () => { + mockHookReturn = { + registryServers: [ + createMockServer({ _id: "1", category: "Productivity" }), + createMockServer({ _id: "2", category: "Developer Tools" }), + ], + categories: ["Developer Tools", "Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect( + screen.getByRole("button", { name: "All" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Productivity" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Developer Tools" }), + ).toBeInTheDocument(); + }); + + it("filters servers when category pill is clicked", () => { + const prodServer = createMockServer({ + _id: "1", + displayName: "Notion", + category: "Productivity", + }); + const devServer = createMockServer({ + _id: "2", + displayName: "GitHub", + category: "Developer Tools", + }); + mockHookReturn = { + registryServers: [prodServer, devServer], + categories: ["Developer Tools", "Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("Notion")).toBeInTheDocument(); + expect(screen.getByText("GitHub")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Productivity" })); + + expect(screen.getByText("Notion")).toBeInTheDocument(); + expect(screen.queryByText("GitHub")).not.toBeInTheDocument(); + }); + }); + + describe("connect/disconnect actions", () => { + it("calls connect when Connect button is clicked", async () => { + const server = createMockServer(); + mockHookReturn = { + registryServers: [server], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + fireEvent.click(screen.getByText("Connect")); + + await waitFor(() => { + expect(mockConnect).toHaveBeenCalledWith(server); + }); + }); + + it("calls disconnect from overflow menu", async () => { + const server = createMockServer({ connectionStatus: "connected" }); + mockHookReturn = { + registryServers: [server], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + // Click disconnect in the mocked dropdown + const disconnectItem = screen.getByText("Disconnect"); + fireEvent.click(disconnectItem); + + await waitFor(() => { + expect(mockDisconnect).toHaveBeenCalledWith(server); + }); + }); + }); + + describe("auto-redirect to App Builder", () => { + it("navigates to app-builder when a pending server becomes connected", async () => { + const server = createMockServer({ displayName: "Asana" }); + mockHookReturn = { + registryServers: [server], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + const onNavigate = vi.fn(); + const { rerender } = render( + , + ); + + // Click connect — stores pending redirect in localStorage + fireEvent.click(screen.getByText("Connect")); + await waitFor(() => expect(mockConnect).toHaveBeenCalled()); + expect(localStorage.getItem("registry-pending-redirect")).toBe("Asana"); + + // Simulate server becoming connected via props update + rerender( + , + ); + + await waitFor(() => { + expect(onNavigate).toHaveBeenCalledWith("app-builder"); + }); + // localStorage should be cleaned up + expect(localStorage.getItem("registry-pending-redirect")).toBeNull(); + }); + + it("survives page remount (OAuth redirect) and still auto-redirects", async () => { + // Simulate: user clicked Connect, got redirected to OAuth, page remounted + localStorage.setItem("registry-pending-redirect", "Linear"); + + const server = createMockServer({ displayName: "Linear" }); + mockHookReturn = { + registryServers: [server], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + const onNavigate = vi.fn(); + + // Mount with server already connected (OAuth callback completed) + render( + , + ); + + await waitFor(() => { + expect(onNavigate).toHaveBeenCalledWith("app-builder"); + }); + expect(localStorage.getItem("registry-pending-redirect")).toBeNull(); + }); + }); +}); diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx index fefa8c14d..ee7b62571 100644 --- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx +++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx @@ -16,6 +16,7 @@ import { GitBranch, GraduationCap, Box, + Package, } from "lucide-react"; import { usePostHog, useFeatureFlagEnabled } from "posthog-js/react"; @@ -122,6 +123,11 @@ const navigationSections: NavSection[] = [ url: "#servers", icon: MCPIcon, }, + { + title: "Registry", + url: "#registry", + icon: Package, + }, { title: "Chat", url: "#chat-v2", diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts new file mode 100644 index 000000000..affa89349 --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -0,0 +1,285 @@ +import { useMemo, useCallback } from "react"; +import { useQuery, useMutation } from "convex/react"; +import type { ServerFormData } from "@/shared/types.js"; + +/** + * Dev-only mock registry servers for local UI testing. + * Set to `true` to bypass Convex and render sample cards. + */ +const DEV_MOCK_REGISTRY = import.meta.env.DEV && true; + +const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ + { + _id: "mock_asana", + slug: "asana", + displayName: "Asana", + description: "Connect to Asana to manage tasks, projects, and team workflows directly from your MCP client.", + publisher: "MCPJam", + category: "Project Management", + iconUrl: "https://cdn.worldvectorlogo.com/logos/asana-logo.svg", + transport: { type: "http", url: "https://mcp.asana.com/v2/mcp", useOAuth: true, oauthScopes: ["default"] }, + approved: true, + featured: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + _id: "mock_linear", + slug: "linear", + displayName: "Linear", + description: "Interact with Linear issues, projects, and cycles. Create, update, and search issues with natural language.", + publisher: "MCPJam", + category: "Project Management", + iconUrl: "https://asset.brandfetch.io/iduDa181eM/idYoMflFma.png", + transport: { type: "http", url: "https://mcp.linear.app/mcp", useOAuth: true, oauthScopes: ["read", "write"] }, + approved: true, + featured: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + _id: "mock_notion", + slug: "notion", + displayName: "Notion", + description: "Access and manage Notion pages, databases, and content. Search, create, and update your workspace.", + publisher: "MCPJam", + category: "Productivity", + iconUrl: "https://upload.wikimedia.org/wikipedia/commons/4/45/Notion_app_logo.png", + transport: { type: "http", url: "https://mcp.notion.com/mcp", useOAuth: true, oauthScopes: ["read_content", "update_content"] }, + approved: true, + featured: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + _id: "mock_slack", + slug: "slack", + displayName: "Slack", + description: "Send messages, search conversations, and manage Slack channels directly through MCP.", + publisher: "MCPJam", + category: "Communication", + iconUrl: "https://cdn.worldvectorlogo.com/logos/slack-new-logo.svg", + transport: { type: "http", url: "https://mcp.slack.com/sse", useOAuth: true }, + approved: true, + featured: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + _id: "mock_github", + slug: "github", + displayName: "GitHub", + description: "Manage repositories, pull requests, issues, and code reviews. Automate your GitHub workflows.", + publisher: "MCPJam", + category: "Developer Tools", + transport: { type: "http", url: "https://mcp.github.com/sse", useOAuth: true, oauthScopes: ["repo", "read:org"] }, + approved: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + _id: "mock_jira", + slug: "jira", + displayName: "Jira", + description: "Create and manage Jira issues, sprints, and boards. Track project progress with natural language.", + publisher: "MCPJam", + category: "Project Management", + transport: { type: "http", url: "https://mcp.atlassian.com/jira/sse", useOAuth: true }, + approved: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + _id: "mock_google_drive", + slug: "google-drive", + displayName: "Google Drive", + description: "Search, read, and organize files in Google Drive. Access documents, spreadsheets, and presentations.", + publisher: "MCPJam", + category: "Productivity", + transport: { type: "http", url: "https://mcp.googleapis.com/drive/sse", useOAuth: true }, + approved: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + { + _id: "mock_stripe", + slug: "stripe", + displayName: "Stripe", + description: "Query payments, subscriptions, and customer data. Monitor your Stripe business metrics.", + publisher: "MCPJam", + category: "Finance", + transport: { type: "http", url: "https://mcp.stripe.com/sse" }, + approved: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, +]; + +/** + * Shape of a registry server document from the Convex backend. + * Matches the `registryServers` table schema. + */ +export interface RegistryServer { + _id: string; + slug: string; + displayName: string; + description: string; + publisher: string; + category: string; + iconUrl?: string; + transport: { + type: "http"; + url: string; + useOAuth?: boolean; + oauthScopes?: string[]; + clientId?: string; + }; + approved: boolean; + featured?: boolean; + createdAt: number; + updatedAt: number; +} + +/** + * Shape of a registry server connection from `registryServerConnections`. + */ +export interface RegistryServerConnection { + _id: string; + registryServerId: string; + workspaceId: string; + connectedAt: number; +} + +export type RegistryConnectionStatus = + | "not_connected" + | "added" + | "connected" + | "connecting"; + +export interface EnrichedRegistryServer extends RegistryServer { + connectionStatus: RegistryConnectionStatus; +} + +/** + * Hook for fetching registry servers and managing connections. + * + * Pattern follows useWorkspaceMutations / useServerMutations in useWorkspaces.ts. + */ +export function useRegistryServers({ + workspaceId, + isAuthenticated, + liveServers, + onConnect, + onDisconnect, +}: { + workspaceId: string | null; + isAuthenticated: boolean; + /** Live MCP connection state from the app, keyed by server name */ + liveServers?: Record; + onConnect: (formData: ServerFormData) => void; + onDisconnect?: (serverName: string) => void; +}) { + // Fetch all approved registry servers (public — no auth required) + const remoteRegistryServers = useQuery( + "registryServers:listRegistryServers" as any, + DEV_MOCK_REGISTRY ? "skip" : ({} as any), + ) as RegistryServer[] | undefined; + const registryServers = DEV_MOCK_REGISTRY + ? MOCK_REGISTRY_SERVERS + : remoteRegistryServers; + + // Fetch workspace-level connections + const connections = useQuery( + "registryServers:getWorkspaceRegistryConnections" as any, + !DEV_MOCK_REGISTRY && isAuthenticated && workspaceId + ? ({ workspaceId } as any) + : "skip", + ) as RegistryServerConnection[] | undefined; + + const connectMutation = useMutation( + "registryServers:connectRegistryServer" as any, + ); + const disconnectMutation = useMutation( + "registryServers:disconnectRegistryServer" as any, + ); + + // Set of registry server IDs that have a persistent connection in this workspace + const connectedRegistryIds = useMemo(() => { + if (!connections) return new Set(); + return new Set(connections.map((c) => c.registryServerId)); + }, [connections]); + + // Enrich servers with connection status + const enrichedServers = useMemo(() => { + if (!registryServers) return []; + + return registryServers.map((server) => { + const isAddedToWorkspace = connectedRegistryIds.has(server._id); + const liveServer = liveServers?.[server.displayName]; + let connectionStatus: RegistryConnectionStatus = "not_connected"; + + if (liveServer?.connectionStatus === "connected") { + connectionStatus = "connected"; + } else if (liveServer?.connectionStatus === "connecting") { + connectionStatus = "connecting"; + } else if (isAddedToWorkspace) { + connectionStatus = "added"; + } + + return { ...server, connectionStatus }; + }); + }, [registryServers, connectedRegistryIds, liveServers]); + + // Extract unique categories + const categories = useMemo(() => { + const cats = new Set(); + for (const s of enrichedServers) { + if (s.category) cats.add(s.category); + } + return Array.from(cats).sort(); + }, [enrichedServers]); + + const isLoading = !DEV_MOCK_REGISTRY && registryServers === undefined; + + async function connect(server: RegistryServer) { + // 1. Record the connection in Convex (only when authenticated with a workspace) + if (!DEV_MOCK_REGISTRY && isAuthenticated && workspaceId) { + await connectMutation({ + registryServerId: server._id, + workspaceId, + } as any); + } + + // 2. Trigger the local MCP connection + onConnect({ + name: server.displayName, + type: "http", + url: server.transport.url, + useOAuth: server.transport.useOAuth, + oauthScopes: server.transport.oauthScopes, + clientId: server.transport.clientId, + registryServerId: server._id, + }); + } + + async function disconnect(server: RegistryServer) { + // 1. Remove the connection from Convex (only when authenticated with a workspace) + if (!DEV_MOCK_REGISTRY && isAuthenticated && workspaceId) { + await disconnectMutation({ + registryServerId: server._id, + workspaceId, + } as any); + } + + // 2. Trigger the local MCP disconnection + onDisconnect?.(server.displayName); + } + + return { + registryServers: enrichedServers, + categories, + isLoading, + connect, + disconnect, + }; +} diff --git a/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts b/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts index 0678cb264..c0555a1e4 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts @@ -8,7 +8,7 @@ import { describe("hosted-navigation", () => { it("normalizes hash aliases and strips hash prefix", () => { - expect(getNormalizedHashParts("#registry")).toEqual(["servers"]); + expect(getNormalizedHashParts("#registry")).toEqual(["registry"]); expect(getNormalizedHashParts("#/chat")).toEqual(["chat-v2"]); expect(getNormalizedHashParts("prompts")).toEqual(["prompts"]); }); @@ -40,7 +40,7 @@ describe("hosted-navigation", () => { it("returns canonical section for hash synchronization", () => { const resolved = resolveHostedNavigation("#/registry", true); expect(resolved.rawSection).toBe("registry"); - expect(resolved.normalizedSection).toBe("servers"); + expect(resolved.normalizedSection).toBe("registry"); }); it("normalizes organization billing subroutes", () => { diff --git a/mcpjam-inspector/client/src/lib/__tests__/hosted-tab-policy.test.ts b/mcpjam-inspector/client/src/lib/__tests__/hosted-tab-policy.test.ts index 337a244d1..245af9f85 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/hosted-tab-policy.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-tab-policy.test.ts @@ -11,9 +11,10 @@ import { describe("hosted-tab-policy", () => { it("normalizes legacy hash aliases to canonical tabs", () => { - expect(normalizeHostedHashTab("registry")).toBe("servers"); expect(normalizeHostedHashTab("chat")).toBe("chat-v2"); expect(normalizeHostedHashTab("chat-v2")).toBe("chat-v2"); + // "registry" is now a first-class tab, not an alias + expect(normalizeHostedHashTab("registry")).toBe("registry"); }); it("keeps prompts visible in hosted sidebar allow-list", () => { diff --git a/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts b/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts index aef780c43..37491d58a 100644 --- a/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts +++ b/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts @@ -1,10 +1,10 @@ const HASH_TAB_ALIASES = { - registry: "servers", chat: "chat-v2", } as const; export const HOSTED_SIDEBAR_ALLOWED_TABS = [ "servers", + "registry", "chat-v2", "sandboxes", "app-builder", diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index 19212a5c1..b5bf33a72 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -16,6 +16,19 @@ import { captureServerDetailModalOAuthResume } from "@/lib/server-detail-modal-r // Store original fetch for restoration const originalFetch = window.fetch; +/** + * Derive the Convex HTTP actions URL (*.convex.site) from the Convex client URL. + */ +function getConvexSiteUrl(): string | null { + const siteUrl = (import.meta as any).env?.VITE_CONVEX_SITE_URL; + if (siteUrl) return siteUrl; + const cloudUrl = (import.meta as any).env?.VITE_CONVEX_URL; + if (cloudUrl && typeof cloudUrl === "string") { + return cloudUrl.replace(".convex.cloud", ".convex.site"); + } + return null; +} + interface StoredOAuthDiscoveryState { serverUrl: string; discoveryState: OAuthDiscoveryState; @@ -30,9 +43,13 @@ function clearStoredDiscoveryState(serverName: string): void { } /** - * Custom fetch interceptor that proxies OAuth requests through our server to avoid CORS + * Custom fetch interceptor that proxies OAuth requests through our server to avoid CORS. + * When a registryServerId is provided, token exchange/refresh is routed through + * the Convex HTTP registry OAuth endpoints which inject server-side secrets. */ -function createOAuthFetchInterceptor(): typeof fetch { +function createOAuthFetchInterceptor( + registryServerId?: string, +): typeof fetch { return async function interceptedFetch( input: RequestInfo | URL, init?: RequestInit, @@ -53,6 +70,36 @@ function createOAuthFetchInterceptor(): typeof fetch { return await originalFetch(input, init); } + // For registry servers, route token exchange/refresh through Convex HTTP actions + if (registryServerId) { + const isTokenRequest = url.match(/\/token$/); + if (isTokenRequest) { + const convexSiteUrl = getConvexSiteUrl(); + if (convexSiteUrl) { + const body = init?.body ? await serializeBody(init.body) : undefined; + const isRefresh = + typeof body === "object" && + body !== null && + (body as any).grant_type === "refresh_token"; + const endpoint = isRefresh + ? "/registry/oauth/refresh" + : "/registry/oauth/token"; + const response = await originalFetch( + `${convexSiteUrl}${endpoint}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + registryServerId, + ...(typeof body === "object" && body !== null ? body : {}), + }), + }, + ); + return response; + } + } + } + // Proxy OAuth requests through our server try { const isMetadata = url.includes("/.well-known/"); @@ -116,6 +163,8 @@ export interface MCPOAuthOptions { scopes?: string[]; clientId?: string; clientSecret?: string; + /** When set, uses Convex /registry/oauth/* routes instead of standard proxy */ + registryServerId?: string; } export interface OAuthResult { @@ -308,7 +357,7 @@ export async function initiateOAuth( options: MCPOAuthOptions, ): Promise { // Install fetch interceptor for OAuth metadata requests - const interceptedFetch = createOAuthFetchInterceptor(); + const interceptedFetch = createOAuthFetchInterceptor(options.registryServerId); window.fetch = interceptedFetch; try { @@ -326,11 +375,14 @@ export async function initiateOAuth( ); localStorage.setItem("mcp-oauth-pending", options.serverName); - // Store OAuth configuration (scopes) for recovery if connection fails + // Store OAuth configuration (scopes, registryServerId) for recovery if connection fails const oauthConfig: any = {}; if (options.scopes && options.scopes.length > 0) { oauthConfig.scopes = options.scopes; } + if (options.registryServerId) { + oauthConfig.registryServerId = options.registryServerId; + } localStorage.setItem( `mcp-oauth-config-${options.serverName}`, JSON.stringify(oauthConfig), @@ -424,13 +476,22 @@ export async function initiateOAuth( export async function handleOAuthCallback( authorizationCode: string, ): Promise { + // Get pending server name from localStorage (needed before creating interceptor) + const serverName = localStorage.getItem("mcp-oauth-pending"); + + // Read registryServerId from stored OAuth config if present + const storedOAuthConfig = serverName + ? localStorage.getItem(`mcp-oauth-config-${serverName}`) + : null; + const registryServerId = storedOAuthConfig + ? JSON.parse(storedOAuthConfig).registryServerId + : undefined; + // Install fetch interceptor for OAuth metadata requests - const interceptedFetch = createOAuthFetchInterceptor(); + const interceptedFetch = createOAuthFetchInterceptor(registryServerId); window.fetch = interceptedFetch; try { - // Get pending server name from localStorage - const serverName = localStorage.getItem("mcp-oauth-pending"); if (!serverName) { throw new Error("No pending OAuth flow found"); } @@ -596,8 +657,16 @@ export async function waitForTokens( export async function refreshOAuthTokens( serverName: string, ): Promise { + // Read registryServerId from stored OAuth config if present + const storedOAuthConfig = localStorage.getItem( + `mcp-oauth-config-${serverName}`, + ); + const registryServerId = storedOAuthConfig + ? JSON.parse(storedOAuthConfig).registryServerId + : undefined; + // Install fetch interceptor for OAuth metadata requests - const interceptedFetch = createOAuthFetchInterceptor(); + const interceptedFetch = createOAuthFetchInterceptor(registryServerId); window.fetch = interceptedFetch; try { diff --git a/mcpjam-inspector/shared/types.ts b/mcpjam-inspector/shared/types.ts index d8efed169..776bca398 100644 --- a/mcpjam-inspector/shared/types.ts +++ b/mcpjam-inspector/shared/types.ts @@ -598,6 +598,8 @@ export interface ServerFormData { clientId?: string; clientSecret?: string; requestTimeout?: number; + /** Convex _id of the registry server (for OAuth routing via /registry/oauth/token) */ + registryServerId?: string; } export interface OauthTokens { From 3ab1d83242844f9b8a2261942a743e2b8d5cad69 Mon Sep 17 00:00:00 2001 From: prathmeshpatel <25394100+prathmeshpatel@users.noreply.github.com> Date: Thu, 19 Mar 2026 05:01:34 +0000 Subject: [PATCH 02/21] style: auto-fix prettier formatting --- .../components/__tests__/RegistryTab.test.tsx | 8 +- .../client/src/hooks/useRegistryServers.ts | 73 +++++++++++++++---- .../client/src/lib/oauth/mcp-oauth.ts | 27 +++---- 3 files changed, 71 insertions(+), 37 deletions(-) diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index 77e31929a..53985ff58 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -249,9 +249,7 @@ describe("RegistryTab", () => { it("shows Connected badge for connected servers", () => { mockHookReturn = { - registryServers: [ - createMockServer({ connectionStatus: "connected" }), - ], + registryServers: [createMockServer({ connectionStatus: "connected" })], categories: ["Productivity"], isLoading: false, connect: mockConnect, @@ -293,9 +291,7 @@ describe("RegistryTab", () => { render(); - expect( - screen.getByRole("button", { name: "All" }), - ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument(); expect( screen.getByRole("button", { name: "Productivity" }), ).toBeInTheDocument(); diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index affa89349..51a396ab8 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -13,11 +13,17 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ _id: "mock_asana", slug: "asana", displayName: "Asana", - description: "Connect to Asana to manage tasks, projects, and team workflows directly from your MCP client.", + description: + "Connect to Asana to manage tasks, projects, and team workflows directly from your MCP client.", publisher: "MCPJam", category: "Project Management", iconUrl: "https://cdn.worldvectorlogo.com/logos/asana-logo.svg", - transport: { type: "http", url: "https://mcp.asana.com/v2/mcp", useOAuth: true, oauthScopes: ["default"] }, + transport: { + type: "http", + url: "https://mcp.asana.com/v2/mcp", + useOAuth: true, + oauthScopes: ["default"], + }, approved: true, featured: true, createdAt: Date.now(), @@ -27,11 +33,17 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ _id: "mock_linear", slug: "linear", displayName: "Linear", - description: "Interact with Linear issues, projects, and cycles. Create, update, and search issues with natural language.", + description: + "Interact with Linear issues, projects, and cycles. Create, update, and search issues with natural language.", publisher: "MCPJam", category: "Project Management", iconUrl: "https://asset.brandfetch.io/iduDa181eM/idYoMflFma.png", - transport: { type: "http", url: "https://mcp.linear.app/mcp", useOAuth: true, oauthScopes: ["read", "write"] }, + transport: { + type: "http", + url: "https://mcp.linear.app/mcp", + useOAuth: true, + oauthScopes: ["read", "write"], + }, approved: true, featured: true, createdAt: Date.now(), @@ -41,11 +53,18 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ _id: "mock_notion", slug: "notion", displayName: "Notion", - description: "Access and manage Notion pages, databases, and content. Search, create, and update your workspace.", + description: + "Access and manage Notion pages, databases, and content. Search, create, and update your workspace.", publisher: "MCPJam", category: "Productivity", - iconUrl: "https://upload.wikimedia.org/wikipedia/commons/4/45/Notion_app_logo.png", - transport: { type: "http", url: "https://mcp.notion.com/mcp", useOAuth: true, oauthScopes: ["read_content", "update_content"] }, + iconUrl: + "https://upload.wikimedia.org/wikipedia/commons/4/45/Notion_app_logo.png", + transport: { + type: "http", + url: "https://mcp.notion.com/mcp", + useOAuth: true, + oauthScopes: ["read_content", "update_content"], + }, approved: true, featured: true, createdAt: Date.now(), @@ -55,11 +74,16 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ _id: "mock_slack", slug: "slack", displayName: "Slack", - description: "Send messages, search conversations, and manage Slack channels directly through MCP.", + description: + "Send messages, search conversations, and manage Slack channels directly through MCP.", publisher: "MCPJam", category: "Communication", iconUrl: "https://cdn.worldvectorlogo.com/logos/slack-new-logo.svg", - transport: { type: "http", url: "https://mcp.slack.com/sse", useOAuth: true }, + transport: { + type: "http", + url: "https://mcp.slack.com/sse", + useOAuth: true, + }, approved: true, featured: true, createdAt: Date.now(), @@ -69,10 +93,16 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ _id: "mock_github", slug: "github", displayName: "GitHub", - description: "Manage repositories, pull requests, issues, and code reviews. Automate your GitHub workflows.", + description: + "Manage repositories, pull requests, issues, and code reviews. Automate your GitHub workflows.", publisher: "MCPJam", category: "Developer Tools", - transport: { type: "http", url: "https://mcp.github.com/sse", useOAuth: true, oauthScopes: ["repo", "read:org"] }, + transport: { + type: "http", + url: "https://mcp.github.com/sse", + useOAuth: true, + oauthScopes: ["repo", "read:org"], + }, approved: true, createdAt: Date.now(), updatedAt: Date.now(), @@ -81,10 +111,15 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ _id: "mock_jira", slug: "jira", displayName: "Jira", - description: "Create and manage Jira issues, sprints, and boards. Track project progress with natural language.", + description: + "Create and manage Jira issues, sprints, and boards. Track project progress with natural language.", publisher: "MCPJam", category: "Project Management", - transport: { type: "http", url: "https://mcp.atlassian.com/jira/sse", useOAuth: true }, + transport: { + type: "http", + url: "https://mcp.atlassian.com/jira/sse", + useOAuth: true, + }, approved: true, createdAt: Date.now(), updatedAt: Date.now(), @@ -93,10 +128,15 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ _id: "mock_google_drive", slug: "google-drive", displayName: "Google Drive", - description: "Search, read, and organize files in Google Drive. Access documents, spreadsheets, and presentations.", + description: + "Search, read, and organize files in Google Drive. Access documents, spreadsheets, and presentations.", publisher: "MCPJam", category: "Productivity", - transport: { type: "http", url: "https://mcp.googleapis.com/drive/sse", useOAuth: true }, + transport: { + type: "http", + url: "https://mcp.googleapis.com/drive/sse", + useOAuth: true, + }, approved: true, createdAt: Date.now(), updatedAt: Date.now(), @@ -105,7 +145,8 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ _id: "mock_stripe", slug: "stripe", displayName: "Stripe", - description: "Query payments, subscriptions, and customer data. Monitor your Stripe business metrics.", + description: + "Query payments, subscriptions, and customer data. Monitor your Stripe business metrics.", publisher: "MCPJam", category: "Finance", transport: { type: "http", url: "https://mcp.stripe.com/sse" }, diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index b5bf33a72..31f17980b 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -47,9 +47,7 @@ function clearStoredDiscoveryState(serverName: string): void { * When a registryServerId is provided, token exchange/refresh is routed through * the Convex HTTP registry OAuth endpoints which inject server-side secrets. */ -function createOAuthFetchInterceptor( - registryServerId?: string, -): typeof fetch { +function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { return async function interceptedFetch( input: RequestInfo | URL, init?: RequestInit, @@ -84,17 +82,14 @@ function createOAuthFetchInterceptor( const endpoint = isRefresh ? "/registry/oauth/refresh" : "/registry/oauth/token"; - const response = await originalFetch( - `${convexSiteUrl}${endpoint}`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - registryServerId, - ...(typeof body === "object" && body !== null ? body : {}), - }), - }, - ); + const response = await originalFetch(`${convexSiteUrl}${endpoint}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + registryServerId, + ...(typeof body === "object" && body !== null ? body : {}), + }), + }); return response; } } @@ -357,7 +352,9 @@ export async function initiateOAuth( options: MCPOAuthOptions, ): Promise { // Install fetch interceptor for OAuth metadata requests - const interceptedFetch = createOAuthFetchInterceptor(options.registryServerId); + const interceptedFetch = createOAuthFetchInterceptor( + options.registryServerId, + ); window.fetch = interceptedFetch; try { From e730e1a5f6e81eaa96ab0e419f25af25da600297 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Fri, 20 Mar 2026 23:00:28 -0400 Subject: [PATCH 03/21] test --- .../client/src/components/__tests__/ServersTab.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index 2e0862e6c..467716011 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -21,6 +21,7 @@ vi.mock("convex/react", () => ({ useConvexAuth: () => ({ isAuthenticated: false, }), + useQuery: () => undefined, })); vi.mock("@workos-inc/authkit-react", () => ({ From dd8d21ed2d6a1a5aac39505abe1d945b2f8b7499 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Fri, 20 Mar 2026 23:13:50 -0400 Subject: [PATCH 04/21] address comments --- .../client/src/components/RegistryTab.tsx | 8 ++++++ .../client/src/components/ServersTab.tsx | 8 +++--- .../components/__tests__/RegistryTab.test.tsx | 1 + .../client/src/hooks/useRegistryServers.ts | 13 +++++++-- .../client/src/lib/oauth/mcp-oauth.ts | 27 ++++++++++--------- 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index bca82e062..2c9e52d08 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -83,6 +83,14 @@ export function RegistryTab({ localStorage.setItem("registry-pending-redirect", server.displayName); try { await connect(server); + } catch (error) { + if ( + localStorage.getItem("registry-pending-redirect") === + server.displayName + ) { + localStorage.removeItem("registry-pending-redirect"); + } + throw error; } finally { setConnectingIds((prev) => { const next = new Set(prev); diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 8b42195b7..8d03162c2 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -611,9 +611,11 @@ export function ServersTab({
{featuredRegistryServers.map((server) => ( - onConnect({ name: server.displayName, @@ -645,7 +647,7 @@ export function ServersTab({ {server.publisher}

-
+ ))} diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index 53985ff58..401ccfc61 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -73,6 +73,7 @@ describe("RegistryTab", () => { beforeEach(() => { vi.clearAllMocks(); + localStorage.clear(); mockConnect.mockResolvedValue(undefined); mockDisconnect.mockResolvedValue(undefined); mockHookReturn = { diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index 51a396ab8..d31ccb6e2 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -6,7 +6,8 @@ import type { ServerFormData } from "@/shared/types.js"; * Dev-only mock registry servers for local UI testing. * Set to `true` to bypass Convex and render sample cards. */ -const DEV_MOCK_REGISTRY = import.meta.env.DEV && true; +const DEV_MOCK_REGISTRY = + import.meta.env.DEV && import.meta.env.VITE_DEV_MOCK_REGISTRY === "true"; const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ { @@ -280,7 +281,15 @@ export function useRegistryServers({ return Array.from(cats).sort(); }, [enrichedServers]); - const isLoading = !DEV_MOCK_REGISTRY && registryServers === undefined; + const connectionsAreLoading = + !DEV_MOCK_REGISTRY && + isAuthenticated && + !!workspaceId && + connections === undefined; + + const isLoading = + !DEV_MOCK_REGISTRY && + (registryServers === undefined || connectionsAreLoading); async function connect(server: RegistryServer) { // 1. Record the connection in Convex (only when authenticated with a workspace) diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index 31f17980b..cd248ee77 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -42,6 +42,19 @@ function clearStoredDiscoveryState(serverName: string): void { localStorage.removeItem(getDiscoveryStorageKey(serverName)); } +function getStoredRegistryServerId( + serverName: string | null, +): string | undefined { + if (!serverName) return undefined; + try { + const raw = localStorage.getItem(`mcp-oauth-config-${serverName}`); + if (!raw) return undefined; + return JSON.parse(raw).registryServerId; + } catch { + return undefined; + } +} + /** * Custom fetch interceptor that proxies OAuth requests through our server to avoid CORS. * When a registryServerId is provided, token exchange/refresh is routed through @@ -477,12 +490,7 @@ export async function handleOAuthCallback( const serverName = localStorage.getItem("mcp-oauth-pending"); // Read registryServerId from stored OAuth config if present - const storedOAuthConfig = serverName - ? localStorage.getItem(`mcp-oauth-config-${serverName}`) - : null; - const registryServerId = storedOAuthConfig - ? JSON.parse(storedOAuthConfig).registryServerId - : undefined; + const registryServerId = getStoredRegistryServerId(serverName); // Install fetch interceptor for OAuth metadata requests const interceptedFetch = createOAuthFetchInterceptor(registryServerId); @@ -655,12 +663,7 @@ export async function refreshOAuthTokens( serverName: string, ): Promise { // Read registryServerId from stored OAuth config if present - const storedOAuthConfig = localStorage.getItem( - `mcp-oauth-config-${serverName}`, - ); - const registryServerId = storedOAuthConfig - ? JSON.parse(storedOAuthConfig).registryServerId - : undefined; + const registryServerId = getStoredRegistryServerId(serverName); // Install fetch interceptor for OAuth metadata requests const interceptedFetch = createOAuthFetchInterceptor(registryServerId); From 34ea49d64bd77a5b1687e3ccfa69bcc0068ba19b Mon Sep 17 00:00:00 2001 From: prathmeshpatel <25394100+prathmeshpatel@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:15:00 +0000 Subject: [PATCH 05/21] style: auto-fix prettier formatting --- mcpjam-inspector/client/src/components/RegistryTab.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index 2c9e52d08..d14bb6466 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -85,8 +85,7 @@ export function RegistryTab({ await connect(server); } catch (error) { if ( - localStorage.getItem("registry-pending-redirect") === - server.displayName + localStorage.getItem("registry-pending-redirect") === server.displayName ) { localStorage.removeItem("registry-pending-redirect"); } From 1abc2ab792fe80c01e8171c001aa6212e3b28208 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Sat, 21 Mar 2026 00:02:17 -0400 Subject: [PATCH 06/21] address --- .../client/src/components/RegistryTab.tsx | 35 +---- .../client/src/components/ServersTab.tsx | 8 +- .../components/__tests__/RegistryTab.test.tsx | 21 ++- .../client/src/hooks/useRegistryServers.ts | 125 ++++++++++++------ mcpjam-inspector/shared/types.ts | 2 + 5 files changed, 100 insertions(+), 91 deletions(-) diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index d14bb6466..e86157b9d 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from "react"; +import { useState, useEffect } from "react"; import { Package, KeyRound, @@ -46,7 +46,6 @@ export function RegistryTab({ }: RegistryTabProps) { // isAuthenticated is passed through to the hook for Convex mutation gating, // but the registry is always browsable without auth. - const [selectedCategory, setSelectedCategory] = useState(null); const [connectingIds, setConnectingIds] = useState>(new Set()); const { registryServers, categories, isLoading, connect, disconnect } = @@ -71,12 +70,7 @@ export function RegistryTab({ } }, [servers, onNavigate]); - const filteredServers = useMemo(() => { - if (!selectedCategory) return registryServers; - return registryServers.filter( - (s: EnrichedRegistryServer) => s.category === selectedCategory, - ); - }, [registryServers, selectedCategory]); + const filteredServers = registryServers; const handleConnect = async (server: EnrichedRegistryServer) => { setConnectingIds((prev) => new Set(prev).add(server._id)); @@ -132,31 +126,6 @@ export function RegistryTab({

- {/* Category filter pills */} - {categories.length > 1 && ( -
- - {categories.map((cat: string) => ( - - ))} -
- )} - {/* Server cards grid */}
{filteredServers.map((server: EnrichedRegistryServer) => ( diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 8d03162c2..9cd495e49 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -182,7 +182,9 @@ export function ServersTab({ ) as RegistryServer[] | undefined; const featuredRegistryServers = useMemo(() => { if (!registryServers) return []; - const featured = registryServers.filter((s) => s.featured); + const featured = registryServers + .filter((s) => s.sortOrder != null) + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); return (featured.length > 0 ? featured : registryServers).slice(0, 4); }, [registryServers]); const { isVisible: isJsonRpcPanelVisible, toggle: toggleJsonRpcPanel } = @@ -619,11 +621,11 @@ export function ServersTab({ onClick={() => onConnect({ name: server.displayName, - type: "http", + type: server.transport.transportType, url: server.transport.url, useOAuth: server.transport.useOAuth, oauthScopes: server.transport.oauthScopes, - clientId: server.transport.clientId, + oauthCredentialKey: server.transport.oauthCredentialKey, registryServerId: server._id, }) } diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index 401ccfc61..145d618a3 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -278,7 +278,7 @@ describe("RegistryTab", () => { }); describe("category filtering", () => { - it("shows category filter pills when multiple categories exist", () => { + it("does not render category filter pills", () => { mockHookReturn = { registryServers: [ createMockServer({ _id: "1", category: "Productivity" }), @@ -292,16 +292,18 @@ describe("RegistryTab", () => { render(); - expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Productivity" }), - ).toBeInTheDocument(); + screen.queryByRole("button", { name: "All" }), + ).not.toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Developer Tools" }), - ).toBeInTheDocument(); + screen.queryByRole("button", { name: "Productivity" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Developer Tools" }), + ).not.toBeInTheDocument(); }); - it("filters servers when category pill is clicked", () => { + it("shows all servers without filtering", () => { const prodServer = createMockServer({ _id: "1", displayName: "Notion", @@ -324,11 +326,6 @@ describe("RegistryTab", () => { expect(screen.getByText("Notion")).toBeInTheDocument(); expect(screen.getByText("GitHub")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button", { name: "Productivity" })); - - expect(screen.getByText("Notion")).toBeInTheDocument(); - expect(screen.queryByText("GitHub")).not.toBeInTheDocument(); }); }); diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index d31ccb6e2..677c4bece 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -12,47 +12,51 @@ const DEV_MOCK_REGISTRY = const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ { _id: "mock_asana", - slug: "asana", + name: "com.asana.mcp", displayName: "Asana", description: "Connect to Asana to manage tasks, projects, and team workflows directly from your MCP client.", publisher: "MCPJam", category: "Project Management", - iconUrl: "https://cdn.worldvectorlogo.com/logos/asana-logo.svg", + iconUrl: "https://cdn.simpleicons.org/asana", + scope: "global", transport: { - type: "http", + transportType: "http", url: "https://mcp.asana.com/v2/mcp", useOAuth: true, oauthScopes: ["default"], }, - approved: true, - featured: true, + status: "approved", + sortOrder: 1, + createdBy: "mock_user", createdAt: Date.now(), updatedAt: Date.now(), }, { _id: "mock_linear", - slug: "linear", + name: "app.linear.mcp", displayName: "Linear", description: "Interact with Linear issues, projects, and cycles. Create, update, and search issues with natural language.", publisher: "MCPJam", category: "Project Management", - iconUrl: "https://asset.brandfetch.io/iduDa181eM/idYoMflFma.png", + iconUrl: "https://cdn.simpleicons.org/linear", + scope: "global", transport: { - type: "http", + transportType: "http", url: "https://mcp.linear.app/mcp", useOAuth: true, oauthScopes: ["read", "write"], }, - approved: true, - featured: true, + status: "approved", + sortOrder: 2, + createdBy: "mock_user", createdAt: Date.now(), updatedAt: Date.now(), }, { _id: "mock_notion", - slug: "notion", + name: "com.notion.mcp", displayName: "Notion", description: "Access and manage Notion pages, databases, and content. Search, create, and update your workspace.", @@ -60,98 +64,110 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ category: "Productivity", iconUrl: "https://upload.wikimedia.org/wikipedia/commons/4/45/Notion_app_logo.png", + scope: "global", transport: { - type: "http", + transportType: "http", url: "https://mcp.notion.com/mcp", useOAuth: true, oauthScopes: ["read_content", "update_content"], }, - approved: true, - featured: true, + status: "approved", + sortOrder: 3, + createdBy: "mock_user", createdAt: Date.now(), updatedAt: Date.now(), }, { _id: "mock_slack", - slug: "slack", + name: "com.slack.mcp", displayName: "Slack", description: "Send messages, search conversations, and manage Slack channels directly through MCP.", publisher: "MCPJam", category: "Communication", iconUrl: "https://cdn.worldvectorlogo.com/logos/slack-new-logo.svg", + scope: "global", transport: { - type: "http", + transportType: "http", url: "https://mcp.slack.com/sse", useOAuth: true, }, - approved: true, - featured: true, + status: "approved", + sortOrder: 4, + createdBy: "mock_user", createdAt: Date.now(), updatedAt: Date.now(), }, { _id: "mock_github", - slug: "github", + name: "com.github.mcp", displayName: "GitHub", description: "Manage repositories, pull requests, issues, and code reviews. Automate your GitHub workflows.", publisher: "MCPJam", category: "Developer Tools", + scope: "global", transport: { - type: "http", + transportType: "http", url: "https://mcp.github.com/sse", useOAuth: true, oauthScopes: ["repo", "read:org"], }, - approved: true, + status: "approved", + createdBy: "mock_user", createdAt: Date.now(), updatedAt: Date.now(), }, { _id: "mock_jira", - slug: "jira", + name: "com.atlassian.jira.mcp", displayName: "Jira", description: "Create and manage Jira issues, sprints, and boards. Track project progress with natural language.", publisher: "MCPJam", category: "Project Management", + scope: "global", transport: { - type: "http", + transportType: "http", url: "https://mcp.atlassian.com/jira/sse", useOAuth: true, }, - approved: true, + status: "approved", + createdBy: "mock_user", createdAt: Date.now(), updatedAt: Date.now(), }, { _id: "mock_google_drive", - slug: "google-drive", + name: "com.google.drive.mcp", displayName: "Google Drive", description: "Search, read, and organize files in Google Drive. Access documents, spreadsheets, and presentations.", publisher: "MCPJam", category: "Productivity", + scope: "global", transport: { - type: "http", + transportType: "http", url: "https://mcp.googleapis.com/drive/sse", useOAuth: true, }, - approved: true, + status: "approved", + createdBy: "mock_user", createdAt: Date.now(), updatedAt: Date.now(), }, { _id: "mock_stripe", - slug: "stripe", + name: "com.stripe.mcp", displayName: "Stripe", description: "Query payments, subscriptions, and customer data. Monitor your Stripe business metrics.", publisher: "MCPJam", category: "Finance", - transport: { type: "http", url: "https://mcp.stripe.com/sse" }, - approved: true, + scope: "global", + transport: { transportType: "http", url: "https://mcp.stripe.com/sse" }, + status: "approved", + createdBy: "mock_user", createdAt: Date.now(), updatedAt: Date.now(), }, @@ -163,21 +179,41 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ */ export interface RegistryServer { _id: string; - slug: string; + // Identity + name: string; // Reverse-DNS: "com.acme.internal-tools" displayName: string; - description: string; - publisher: string; - category: string; + description?: string; iconUrl?: string; + // Client type: "text" for any MCP client, "app" for rich-UI clients + clientType?: "text" | "app"; + // Scope & ownership + scope: "global" | "organization"; + organizationId?: string; + // Transport config transport: { - type: "http"; - url: string; + transportType: "stdio" | "http"; + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Record; useOAuth?: boolean; oauthScopes?: string[]; - clientId?: string; + oauthCredentialKey?: string; + timeout?: number; }; - approved: boolean; - featured?: boolean; + // Curation + category?: string; + tags?: string[]; + version?: string; + publisher?: string; + repositoryUrl?: string; + sortOrder?: number; + // Governance + status: "approved" | "pending_review" | "deprecated"; + meta?: unknown; + // Tracking + createdBy: string; createdAt: number; updatedAt: number; } @@ -189,7 +225,10 @@ export interface RegistryServerConnection { _id: string; registryServerId: string; workspaceId: string; + serverId: string; // the actual servers row + connectedBy: string; connectedAt: number; + configOverridden?: boolean; } export type RegistryConnectionStatus = @@ -221,10 +260,10 @@ export function useRegistryServers({ onConnect: (formData: ServerFormData) => void; onDisconnect?: (serverName: string) => void; }) { - // Fetch all approved registry servers (public — no auth required) + // Fetch all approved registry servers (requires Convex auth identity) const remoteRegistryServers = useQuery( "registryServers:listRegistryServers" as any, - DEV_MOCK_REGISTRY ? "skip" : ({} as any), + !DEV_MOCK_REGISTRY && isAuthenticated ? ({} as any) : "skip", ) as RegistryServer[] | undefined; const registryServers = DEV_MOCK_REGISTRY ? MOCK_REGISTRY_SERVERS @@ -303,11 +342,11 @@ export function useRegistryServers({ // 2. Trigger the local MCP connection onConnect({ name: server.displayName, - type: "http", + type: server.transport.transportType, url: server.transport.url, useOAuth: server.transport.useOAuth, oauthScopes: server.transport.oauthScopes, - clientId: server.transport.clientId, + oauthCredentialKey: server.transport.oauthCredentialKey, registryServerId: server._id, }); } diff --git a/mcpjam-inspector/shared/types.ts b/mcpjam-inspector/shared/types.ts index 776bca398..cffea9bd0 100644 --- a/mcpjam-inspector/shared/types.ts +++ b/mcpjam-inspector/shared/types.ts @@ -597,6 +597,8 @@ export interface ServerFormData { oauthScopes?: string[]; clientId?: string; clientSecret?: string; + /** Registry credential key for resolving OAuth client ID from env (e.g. "github") */ + oauthCredentialKey?: string; requestTimeout?: number; /** Convex _id of the registry server (for OAuth routing via /registry/oauth/token) */ registryServerId?: string; From dc38a2b0734c479882c90a15ca8347c9af224d8b Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Sun, 22 Mar 2026 16:06:47 -0400 Subject: [PATCH 07/21] progress --- mcpjam-inspector/client/src/App.tsx | 1 + .../client/src/components/RegistryTab.tsx | 245 +++++++++--- .../components/__tests__/RegistryTab.test.tsx | 133 ++++++- .../__tests__/consolidateServers.test.ts | 143 +++++++ .../src/hooks/hosted/use-hosted-oauth-gate.ts | 2 + .../client/src/hooks/use-server-state.ts | 4 + .../client/src/hooks/useRegistryServers.ts | 100 ++++- .../client/src/lib/hosted-oauth-callback.ts | 1 + .../src/lib/oauth/__tests__/mcp-oauth.test.ts | 357 +++++++++++++++++- .../client/src/lib/oauth/mcp-oauth.ts | 289 +++++++++++--- .../client/src/state/oauth-orchestrator.ts | 1 + 11 files changed, 1144 insertions(+), 132 deletions(-) create mode 100644 mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index 860e17686..dcd3d7a5e 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -235,6 +235,7 @@ export default function App() { } clearHostedOAuthPendingState(); + console.log("[OAuthDebug] REMOVE mcp-oauth-pending (App.tsx handleOAuthError)"); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); localStorage.removeItem("mcp-oauth-return-hash"); const returnHash = resolveHostedOAuthReturnHash(callbackContext); diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index e86157b9d..f02582acb 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Package, KeyRound, @@ -7,6 +7,9 @@ import { Loader2, MoreVertical, Unplug, + MonitorSmartphone, + MessageSquareText, + ChevronDown, } from "lucide-react"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; @@ -17,11 +20,14 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { useRegistryServers, + consolidateServers, type EnrichedRegistryServer, + type ConsolidatedRegistryServer, type RegistryConnectionStatus, } from "@/hooks/useRegistryServers"; import type { ServerFormData } from "@/shared/types.js"; @@ -70,7 +76,10 @@ export function RegistryTab({ } }, [servers, onNavigate]); - const filteredServers = registryServers; + const consolidatedServers = useMemo( + () => consolidateServers(registryServers), + [registryServers], + ); const handleConnect = async (server: EnrichedRegistryServer) => { setConnectingIds((prev) => new Set(prev).add(server._id)); @@ -128,13 +137,13 @@ export function RegistryTab({ {/* Server cards grid */}
- {filteredServers.map((server: EnrichedRegistryServer) => ( + {consolidatedServers.map((consolidated) => ( handleConnect(server)} - onDisconnect={() => handleDisconnect(server)} + key={consolidated.variants[0]._id} + consolidated={consolidated} + connectingIds={connectingIds} + onConnect={handleConnect} + onDisconnect={handleDisconnect} /> ))}
@@ -144,30 +153,32 @@ export function RegistryTab({ } function RegistryServerCard({ - server, - isConnecting, + consolidated, + connectingIds, onConnect, onDisconnect, }: { - server: EnrichedRegistryServer; - isConnecting: boolean; - onConnect: () => void; - onDisconnect: () => void; + consolidated: ConsolidatedRegistryServer; + connectingIds: Set; + onConnect: (server: EnrichedRegistryServer) => void; + onDisconnect: (server: EnrichedRegistryServer) => void; }) { + const { variants, hasDualType } = consolidated; + const first = variants[0]; + + const isConnecting = variants.some((v) => connectingIds.has(v._id)); const effectiveStatus: RegistryConnectionStatus = isConnecting ? "connecting" - : server.connectionStatus; - const isConnectedOrAdded = - effectiveStatus === "connected" || effectiveStatus === "added"; + : first.connectionStatus; return ( - {/* Top row: icon + name + auth pill + action (top-right) */} + {/* Top row: icon + name + action (top-right) */}
- {server.iconUrl ? ( + {first.iconUrl ? ( {server.displayName} ) : ( @@ -176,20 +187,14 @@ function RegistryServerCard({
)}
-
-

- {server.displayName} -

- - - {server.category} - -
+

+ {first.displayName} +

- {server.publisher} + {first.publisher} - {server.publisher === "MCPJam" && ( + {first.publisher === "MCPJam" && ( {/* Top-right action */}
- + {hasDualType ? ( + + ) : ( + onConnect(first)} + onDisconnect={() => onDisconnect(first)} + /> + )}
+ {/* Tags row — show badges for all variants */} +
+ {variants.map((v) => ( + + ))} + +
+ {/* Description */}

- {server.description} + {first.description}

); } +function DualTypeAction({ + variants, + connectingIds, + onConnect, + onDisconnect, +}: { + variants: EnrichedRegistryServer[]; + connectingIds: Set; + onConnect: (server: EnrichedRegistryServer) => void; + onDisconnect: (server: EnrichedRegistryServer) => void; +}) { + // Check if any variant is connecting + const connectingVariant = variants.find((v) => connectingIds.has(v._id)); + if (connectingVariant) { + return ( + + ); + } + + // Check if any variant is connected/added + const connectedVariant = variants.find( + (v) => v.connectionStatus === "connected", + ); + const addedVariant = variants.find((v) => v.connectionStatus === "added"); + const activeVariant = connectedVariant ?? addedVariant; + + if (activeVariant) { + const label = + activeVariant.connectionStatus === "connected" ? "Connected" : "Added"; + const disconnectLabel = + activeVariant.connectionStatus === "connected" ? "Disconnect" : "Remove"; + + // Show connected state + dropdown for remaining variants + const remainingVariants = variants.filter((v) => v !== activeVariant); + + return ( +
+ {activeVariant.connectionStatus === "connected" ? ( + + ) : ( + + )} + + + + + + {remainingVariants.map((v) => ( + onConnect(v)}> + {v.clientType === "app" ? ( + + ) : ( + + )} + Connect as {v.clientType === "app" ? "App" : "Text"} + + ))} + + onDisconnect(activeVariant)}> + + {disconnectLabel} {activeVariant.clientType === "app" ? "App" : "Text"} + + + +
+ ); + } + + // Neither variant connected — show split Connect button with dropdown + return ( + + + + + + {variants.map((v) => ( + onConnect(v)}> + {v.clientType === "app" ? ( + + ) : ( + + )} + Connect as {v.clientType === "app" ? "App" : "Text"} + + ))} + + + ); +} + +function ClientTypeBadge({ clientType }: { clientType?: "text" | "app" }) { + if (clientType === "app") { + return ( + + + App + + ); + } + return ( + + + Text + + ); +} + function AuthBadge({ useOAuth }: { useOAuth?: boolean }) { if (useOAuth) { return ( - + OAuth ); @@ -241,9 +402,9 @@ function AuthBadge({ useOAuth }: { useOAuth?: boolean }) { return ( - + No auth ); diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index 145d618a3..38f19b9b7 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -14,9 +14,14 @@ let mockHookReturn: { disconnect: typeof mockDisconnect; }; -vi.mock("@/hooks/useRegistryServers", () => ({ - useRegistryServers: () => mockHookReturn, -})); +vi.mock("@/hooks/useRegistryServers", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useRegistryServers: () => mockHookReturn, + }; +}); // Mock dropdown menu to simplify testing vi.mock("../ui/dropdown-menu", () => ({ @@ -29,6 +34,7 @@ vi.mock("../ui/dropdown-menu", () => ({ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
{children}
), + DropdownMenuSeparator: () =>
, DropdownMenuItem: ({ children, onClick, @@ -215,7 +221,6 @@ describe("RegistryTab", () => { screen.getByText("Manage Linear issues and projects."), ).toBeInTheDocument(); expect(screen.getByText("MCPJam")).toBeInTheDocument(); - expect(screen.getByText("Project Management")).toBeInTheDocument(); }); it("does not show raw URL by default", () => { @@ -454,4 +459,124 @@ describe("RegistryTab", () => { expect(localStorage.getItem("registry-pending-redirect")).toBeNull(); }); }); + + describe("consolidated cards — dual-type servers", () => { + function createFullServer( + overrides: Partial & { _id: string; displayName: string }, + ): EnrichedRegistryServer { + return { + name: `com.test.${overrides.displayName.toLowerCase()}`, + description: `${overrides.displayName} description`, + scope: "global" as const, + transport: { + transportType: "http" as const, + url: `https://${overrides.displayName.toLowerCase()}.example.com`, + useOAuth: true, + }, + category: "Productivity", + publisher: overrides.displayName, + status: "approved" as const, + createdBy: "test", + createdAt: Date.now(), + updatedAt: Date.now(), + connectionStatus: "not_connected", + clientType: "text", + ...overrides, + }; + } + + it("renders one card per consolidated server (dual-type = 1 card)", () => { + mockHookReturn = { + registryServers: [ + createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), + createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), + createFullServer({ _id: "linear-1", displayName: "Linear", clientType: "text" }), + ], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + const headings = screen.getAllByRole("heading", { level: 3 }); + const names = headings.map((h) => h.textContent); + expect(names.filter((n) => n === "Asana")).toHaveLength(1); + expect(names.filter((n) => n === "Linear")).toHaveLength(1); + expect(headings).toHaveLength(2); + }); + + it("shows both Text and App badges on dual-type card", () => { + mockHookReturn = { + registryServers: [ + createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), + createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), + ], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByText("Text")).toBeInTheDocument(); + expect(screen.getByText("App")).toBeInTheDocument(); + }); + + it("shows dropdown trigger for dual-type card", () => { + mockHookReturn = { + registryServers: [ + createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), + createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), + ], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByTestId("connect-dropdown-trigger")).toBeInTheDocument(); + }); + + it("does not show dropdown trigger for single-type card", () => { + mockHookReturn = { + registryServers: [ + createFullServer({ _id: "linear-1", displayName: "Linear", clientType: "text" }), + ], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.queryByTestId("connect-dropdown-trigger")).toBeNull(); + }); + + it("dropdown contains Connect as Text and Connect as App options", async () => { + mockHookReturn = { + registryServers: [ + createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), + createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), + ], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + // With the mocked dropdown, items are always visible + const items = screen.getAllByTestId("dropdown-item"); + const itemTexts = items.map((el) => el.textContent); + expect(itemTexts.some((t) => t?.includes("Text"))).toBe(true); + expect(itemTexts.some((t) => t?.includes("App"))).toBe(true); + }); + }); }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts b/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts new file mode 100644 index 000000000..177da84d4 --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; +import { + consolidateServers, + type EnrichedRegistryServer, +} from "../useRegistryServers"; + +/** Minimal factory for test fixtures */ +function makeServer( + overrides: Partial & { + _id: string; + displayName: string; + }, +): EnrichedRegistryServer { + return { + name: `com.test.${overrides.displayName.toLowerCase()}`, + description: `${overrides.displayName} server`, + scope: "global" as const, + transport: { + transportType: "http" as const, + url: `https://${overrides.displayName.toLowerCase()}.example.com`, + useOAuth: true, + }, + category: "productivity", + publisher: overrides.displayName, + status: "approved" as const, + createdBy: "test", + createdAt: Date.now(), + updatedAt: Date.now(), + connectionStatus: "not_connected", + clientType: "text", + ...overrides, + }; +} + +describe("consolidateServers", () => { + it("returns single-type servers unchanged", () => { + const linear = makeServer({ + _id: "linear-1", + displayName: "Linear", + clientType: "text", + }); + + const result = consolidateServers([linear]); + + expect(result).toHaveLength(1); + expect(result[0].variants[0]).toBe(linear); + expect(result[0].variants).toHaveLength(1); + expect(result[0].hasDualType).toBe(false); + }); + + it("groups dual-type servers by displayName", () => { + const asanaText = makeServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }); + const asanaApp = makeServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }); + + const result = consolidateServers([asanaText, asanaApp]); + + expect(result).toHaveLength(1); + expect(result[0].hasDualType).toBe(true); + expect(result[0].variants).toHaveLength(2); + expect(result[0].variants).toContain(asanaText); + expect(result[0].variants).toContain(asanaApp); + }); + + it("preserves all single-type servers alongside consolidated ones", () => { + const asanaText = makeServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }); + const asanaApp = makeServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }); + const linear = makeServer({ + _id: "linear-1", + displayName: "Linear", + clientType: "text", + }); + const notion = makeServer({ + _id: "notion-1", + displayName: "Notion", + clientType: "text", + }); + + const result = consolidateServers([asanaText, asanaApp, linear, notion]); + + expect(result).toHaveLength(3); + + const asanaGroup = result.find((c) => c.variants[0].displayName === "Asana"); + expect(asanaGroup?.hasDualType).toBe(true); + expect(asanaGroup?.variants).toHaveLength(2); + + const linearGroup = result.find((c) => c.variants[0].displayName === "Linear"); + expect(linearGroup?.hasDualType).toBe(false); + expect(linearGroup?.variants).toHaveLength(1); + + const notionGroup = result.find((c) => c.variants[0].displayName === "Notion"); + expect(notionGroup?.hasDualType).toBe(false); + expect(notionGroup?.variants).toHaveLength(1); + }); + + it("orders app before text regardless of input order", () => { + const asanaText = makeServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }); + const asanaApp = makeServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }); + + const result = consolidateServers([asanaText, asanaApp]); + + expect(result).toHaveLength(1); + expect(result[0].variants[0].clientType).toBe("app"); + expect(result[0].variants[0]._id).toBe("asana-app"); + }); + + it("handles servers with no clientType as single-type", () => { + const mystery = makeServer({ + _id: "mystery-1", + displayName: "Mystery", + clientType: undefined, + }); + + const result = consolidateServers([mystery]); + + expect(result).toHaveLength(1); + expect(result[0].hasDualType).toBe(false); + expect(result[0].variants).toHaveLength(1); + }); +}); diff --git a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts index 1b11bf149..7fc8d4fdf 100644 --- a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts +++ b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts @@ -373,6 +373,7 @@ export function useHostedOAuthGate({ if (!result.success) { clearHostedOAuthPendingState(); + console.log("[OAuthDebug] REMOVE mcp-oauth-pending (use-hosted-oauth-gate failure)"); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); localStorage.removeItem("mcp-oauth-return-hash"); localStorage.removeItem(pendingKey); @@ -398,6 +399,7 @@ export function useHostedOAuthGate({ if (accessToken) { clearHostedOAuthPendingState(); + console.log("[OAuthDebug] REMOVE mcp-oauth-pending (use-hosted-oauth-gate success)"); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); localStorage.removeItem("mcp-oauth-return-hash"); localStorage.removeItem(pendingKey); diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 15777fd3c..194477fea 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -136,6 +136,7 @@ export function useServerState({ const failPendingOAuthConnection = useCallback( (errorMessage: string) => { const pendingServerName = localStorage.getItem("mcp-oauth-pending"); + console.log("[OAuthDebug] failPendingOAuthConnection:", pendingServerName, "error:", errorMessage); // ##TODOClean if (pendingServerName) { dispatch({ type: "CONNECT_FAILURE", @@ -145,6 +146,7 @@ export function useServerState({ } localStorage.removeItem("mcp-oauth-return-hash"); + console.log("[OAuthDebug] REMOVE mcp-oauth-pending (failPendingOAuthConnection)"); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); return pendingServerName; @@ -475,6 +477,7 @@ export function useServerState({ const handleOAuthCallbackComplete = useCallback( async (code: string) => { const pendingServerName = localStorage.getItem("mcp-oauth-pending"); + console.log("[OAuthDebug] handleOAuthCallbackComplete: mcp-oauth-pending =", pendingServerName); // ##TODOClean try { const result = await handleOAuthCallback(code); @@ -787,6 +790,7 @@ export function useServerState({ serverUrl: formData.url, clientId: formData.clientId, clientSecret: formData.clientSecret, + registryServerId: formData.registryServerId, }; if (formData.oauthScopes && formData.oauthScopes.length > 0) { oauthOptions.scopes = formData.oauthScopes; diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index 677c4bece..855160d1a 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -1,4 +1,4 @@ -import { useMemo, useCallback } from "react"; +import { useMemo, useCallback, useState, useEffect } from "react"; import { useQuery, useMutation } from "convex/react"; import type { ServerFormData } from "@/shared/types.js"; @@ -200,6 +200,7 @@ export interface RegistryServer { useOAuth?: boolean; oauthScopes?: string[]; oauthCredentialKey?: string; + clientId?: string; timeout?: number; }; // Curation @@ -241,6 +242,56 @@ export interface EnrichedRegistryServer extends RegistryServer { connectionStatus: RegistryConnectionStatus; } +/** + * Registry servers grouped by displayName, with variants ordered app-first. + */ +export interface ConsolidatedRegistryServer { + /** All variants ordered: app before text. */ + variants: EnrichedRegistryServer[]; + /** True when both "text" and "app" variants exist. */ + hasDualType: boolean; +} + +/** + * Groups registry servers by displayName. Variants are ordered app before text. + */ +export function consolidateServers( + servers: EnrichedRegistryServer[], +): ConsolidatedRegistryServer[] { + const groups = new Map(); + + for (const server of servers) { + const key = server.displayName; + const group = groups.get(key); + if (group) { + group.push(server); + } else { + groups.set(key, [server]); + } + } + + const result: ConsolidatedRegistryServer[] = []; + + for (const variants of groups.values()) { + // App before text + const ordered = [...variants].sort((a) => + a.clientType === "app" ? -1 : 1, + ); + result.push({ variants: ordered, hasDualType: variants.length > 1 }); + } + + return result; +} + +/** + * Returns the server name that matches what Convex creates (with (App)/(Text) suffix). + */ +function getRegistryServerName(server: RegistryServer): string { + if (server.clientType === "app") return `${server.displayName} (App)`; + if (server.clientType === "text") return `${server.displayName} (Text)`; + return server.displayName; +} + /** * Hook for fetching registry servers and managing connections. * @@ -296,7 +347,7 @@ export function useRegistryServers({ return registryServers.map((server) => { const isAddedToWorkspace = connectedRegistryIds.has(server._id); - const liveServer = liveServers?.[server.displayName]; + const liveServer = liveServers?.[getRegistryServerName(server)]; let connectionStatus: RegistryConnectionStatus = "not_connected"; if (liveServer?.connectionStatus === "connected") { @@ -320,6 +371,36 @@ export function useRegistryServers({ return Array.from(cats).sort(); }, [enrichedServers]); + // Track registry server IDs that are pending connection (waiting for OAuth / handshake) + const [pendingServerIds, setPendingServerIds] = useState< + Map + >(new Map()); // registryServerId → suffixed server name + + // Record the Convex connection only after the server actually connects + useEffect(() => { + if (!isAuthenticated || !workspaceId || DEV_MOCK_REGISTRY) return; + for (const [registryServerId, serverName] of pendingServerIds) { + const liveServer = liveServers?.[serverName]; + if (liveServer?.connectionStatus === "connected") { + setPendingServerIds((prev) => { + const next = new Map(prev); + next.delete(registryServerId); + return next; + }); + connectMutation({ + registryServerId, + workspaceId, + } as any); + } + } + }, [ + liveServers, + pendingServerIds, + isAuthenticated, + workspaceId, + connectMutation, + ]); + const connectionsAreLoading = !DEV_MOCK_REGISTRY && isAuthenticated && @@ -331,22 +412,19 @@ export function useRegistryServers({ (registryServers === undefined || connectionsAreLoading); async function connect(server: RegistryServer) { - // 1. Record the connection in Convex (only when authenticated with a workspace) - if (!DEV_MOCK_REGISTRY && isAuthenticated && workspaceId) { - await connectMutation({ - registryServerId: server._id, - workspaceId, - } as any); - } + const serverName = getRegistryServerName(server); + // Track this server as pending — Convex record will be created when it actually connects + setPendingServerIds((prev) => new Map(prev).set(server._id, serverName)); - // 2. Trigger the local MCP connection + // Trigger the local MCP connection onConnect({ - name: server.displayName, + name: serverName, type: server.transport.transportType, url: server.transport.url, useOAuth: server.transport.useOAuth, oauthScopes: server.transport.oauthScopes, oauthCredentialKey: server.transport.oauthCredentialKey, + clientId: server.transport.clientId, registryServerId: server._id, }); } diff --git a/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts b/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts index b03aa015f..c239000d3 100644 --- a/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts +++ b/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts @@ -198,6 +198,7 @@ export function getHostedOAuthCallbackContext(): HostedOAuthCallbackContext | nu } const serverName = localStorage.getItem("mcp-oauth-pending")?.trim() ?? ""; + console.log("[OAuthDebug] hosted-oauth-callback: mcp-oauth-pending =", serverName || "(empty)"); // ##TODOClean if (!serverName) { return null; } diff --git a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts index 3ad555b0b..040a1c6d6 100644 --- a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts +++ b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts @@ -6,12 +6,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const { mockSdkAuth } = vi.hoisted(() => ({ +const { + mockDiscoverAuthorizationServerMetadata, + mockDiscoverOAuthServerInfo, + mockFetchToken, + mockSdkAuth, + mockSelectResourceURL, +} = vi.hoisted(() => ({ + mockDiscoverAuthorizationServerMetadata: vi.fn(), + mockDiscoverOAuthServerInfo: vi.fn(), + mockFetchToken: vi.fn(), mockSdkAuth: vi.fn(), + mockSelectResourceURL: vi.fn(), })); vi.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: mockSdkAuth, + discoverAuthorizationServerMetadata: mockDiscoverAuthorizationServerMetadata, + discoverOAuthServerInfo: mockDiscoverOAuthServerInfo, + fetchToken: mockFetchToken, + selectResourceURL: mockSelectResourceURL, })); vi.mock("@/lib/session-token", () => ({ @@ -40,6 +54,31 @@ function createDiscoveryState(): any { }; } +function createAsanaDiscoveryState(): any { + return { + authorizationServerUrl: "https://app.asana.com", + resourceMetadataUrl: + "https://mcp.asana.com/.well-known/oauth-protected-resource/v2/mcp", + resourceMetadata: { + resource: "https://mcp.asana.com/v2/mcp", + authorization_servers: ["https://app.asana.com"], + }, + authorizationServerMetadata: { + issuer: "https://app.asana.com", + authorization_endpoint: "https://app.asana.com/-/oauth_authorize", + token_endpoint: "https://app.asana.com/-/oauth_token", + registration_endpoint: "https://app.asana.com/-/oauth_register", + }, + }; +} + +function createJsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + describe("mcp-oauth", () => { let authFetch: ReturnType; @@ -47,19 +86,49 @@ describe("mcp-oauth", () => { vi.resetModules(); localStorage.clear(); sessionStorage.clear(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); mockSdkAuth.mockReset(); + mockDiscoverAuthorizationServerMetadata.mockReset(); + mockDiscoverOAuthServerInfo.mockReset(); + mockFetchToken.mockReset(); + mockSelectResourceURL.mockReset(); const sessionToken = await import("@/lib/session-token"); authFetch = sessionToken.authFetch as ReturnType; authFetch.mockReset(); + mockSelectResourceURL.mockResolvedValue(undefined); }); afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); localStorage.clear(); sessionStorage.clear(); }); + async function seedPendingOAuth( + registryServerId?: string, + discoveryState: any = createAsanaDiscoveryState(), + ) { + mockSdkAuth.mockImplementationOnce(async (provider) => { + await provider.saveDiscoveryState?.(discoveryState); + await provider.saveCodeVerifier("test-verifier"); + return "REDIRECT"; + }); + + const { initiateOAuth } = await import("../mcp-oauth"); + const result = await initiateOAuth({ + serverName: "asana", + serverUrl: "https://mcp.asana.com/v2/mcp", + registryServerId, + }); + + expect(result).toEqual({ success: true }); + return discoveryState; + } + describe("proxy endpoint auth failures", () => { it("returns 401 response directly when auth fails on proxy endpoint", async () => { const authErrorResponse = new Response( @@ -233,20 +302,21 @@ describe("mcp-oauth", () => { it("reuses cached discovery state after the callback reload", async () => { const discoveryState = createDiscoveryState(); - mockSdkAuth - .mockImplementationOnce(async (provider) => { - await provider.saveDiscoveryState?.(discoveryState); - return "REDIRECT"; - }) - .mockImplementationOnce(async (provider, options) => { - expect(options.authorizationCode).toBe("oauth-code"); - expect(provider.discoveryState?.()).toEqual(discoveryState); - await provider.saveTokens({ - access_token: "access-token", - refresh_token: "refresh-token", - }); - return "AUTHORIZED"; - }); + mockSdkAuth.mockImplementationOnce(async (provider) => { + await provider.saveDiscoveryState?.(discoveryState); + await provider.saveCodeVerifier("code-verifier"); + return "REDIRECT"; + }); + mockFetchToken.mockImplementationOnce(async (provider, _url, options) => { + expect(options?.authorizationCode).toBe("oauth-code"); + expect(provider.discoveryState?.()).toEqual(discoveryState); + expect(provider.codeVerifier()).toBe("code-verifier"); + return { + access_token: "access-token", + refresh_token: "refresh-token", + token_type: "Bearer", + }; + }); const { getStoredTokens, handleOAuthCallback, initiateOAuth } = await import("../mcp-oauth"); @@ -266,7 +336,8 @@ describe("mcp-oauth", () => { }); expect(getStoredTokens("asana")?.access_token).toBe("access-token"); expect(localStorage.getItem("mcp-discovery-asana")).not.toBeNull(); - expect(mockSdkAuth).toHaveBeenCalledTimes(2); + expect(mockSdkAuth).toHaveBeenCalledTimes(1); + expect(mockFetchToken).toHaveBeenCalledTimes(1); }); it("treats malformed stored token data as invalid instead of throwing", async () => { @@ -282,5 +353,259 @@ describe("mcp-oauth", () => { isInvalid: true, }); }); + + it("routes Asana-style callback token exchange through Convex for registry servers", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); + const browserFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + + if (url === "https://example.convex.site/registry/oauth/token") { + return createJsonResponse({ + access_token: "access-token", + refresh_token: "refresh-token", + token_type: "Bearer", + }); + } + + throw new Error(`Unexpected direct fetch to ${url}`); + }); + vi.stubGlobal("fetch", browserFetch); + + const discoveryState = createAsanaDiscoveryState(); + await seedPendingOAuth("registry-asana", discoveryState); + mockFetchToken.mockImplementationOnce(async (provider, authServerUrl, options) => { + expect(authServerUrl).toBe("https://app.asana.com"); + expect(options?.metadata?.token_endpoint).toBe( + "https://app.asana.com/-/oauth_token", + ); + const response = await options!.fetchFn!( + "https://app.asana.com/-/oauth_token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + return await response.json(); + }); + + const { handleOAuthCallback } = await import("../mcp-oauth"); + const callbackResult = await handleOAuthCallback("oauth-code"); + + expect(callbackResult.success).toBe(true); + expect(browserFetch).toHaveBeenCalledTimes(1); + expect(browserFetch).toHaveBeenCalledWith( + "https://example.convex.site/registry/oauth/token", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + registryServerId: "registry-asana", + grant_type: "authorization_code", + grantType: "authorization_code", + code: "oauth-code", + code_verifier: "test-verifier", + codeVerifier: "test-verifier", + redirect_uri: `${window.location.origin}/oauth/callback`, + redirectUri: `${window.location.origin}/oauth/callback`, + }), + }), + ); + }); + + it("routes Asana-style refresh token exchange through Convex for registry servers", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); + const browserFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + + if (url === "https://example.convex.site/registry/oauth/refresh") { + return createJsonResponse({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + token_type: "Bearer", + }); + } + + throw new Error(`Unexpected direct fetch to ${url}`); + }); + vi.stubGlobal("fetch", browserFetch); + + mockSdkAuth.mockImplementationOnce(async (_provider, options) => { + const response = await options.fetchFn!( + "https://app.asana.com/-/oauth_token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "stored-refresh-token", + }), + }, + ); + const tokens = await response.json(); + await _provider.saveTokens(tokens); + return "AUTHORIZED"; + }); + + localStorage.setItem("mcp-serverUrl-asana", "https://mcp.asana.com/v2/mcp"); + localStorage.setItem( + "mcp-oauth-config-asana", + JSON.stringify({ registryServerId: "registry-asana" }), + ); + localStorage.setItem( + "mcp-tokens-asana", + JSON.stringify({ + access_token: "old-access-token", + refresh_token: "stored-refresh-token", + token_type: "Bearer", + }), + ); + + const { refreshOAuthTokens } = await import("../mcp-oauth"); + const refreshResult = await refreshOAuthTokens("asana"); + + expect(refreshResult.success).toBe(true); + expect(browserFetch).toHaveBeenCalledTimes(1); + expect(browserFetch).toHaveBeenCalledWith( + "https://example.convex.site/registry/oauth/refresh", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + registryServerId: "registry-asana", + grant_type: "refresh_token", + grantType: "refresh_token", + refresh_token: "stored-refresh-token", + refreshToken: "stored-refresh-token", + }), + }), + ); + }); + + it("preserves the original callback error and verifier when registry token exchange fails", async () => { + vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); + const browserFetch = vi.fn(async (input: RequestInfo | URL) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + + if (url === "https://example.convex.site/registry/oauth/token") { + return createJsonResponse( + { + error: "invalid_client", + error_description: "Client authentication failed", + }, + 401, + ); + } + + throw new Error(`Unexpected direct fetch to ${url}`); + }); + vi.stubGlobal("fetch", browserFetch); + + await seedPendingOAuth("registry-asana"); + mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { + const response = await options!.fetchFn!( + "https://app.asana.com/-/oauth_token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + if (!response.ok) { + const payload = await response.json(); + throw new Error(`${payload.error}: ${payload.error_description}`); + } + return await response.json(); + }); + + const { handleOAuthCallback } = await import("../mcp-oauth"); + const callbackResult = await handleOAuthCallback("oauth-code"); + + expect(callbackResult.success).toBe(false); + expect(callbackResult.error).not.toBe("Code verifier not found"); + expect(callbackResult.error).toContain( + "Invalid client ID during token exchange", + ); + expect(localStorage.getItem("mcp-verifier-asana")).toBe("test-verifier"); + }); + + it("uses the generic Inspector OAuth proxy for non-registry token exchange", async () => { + const browserFetch = vi.fn(); + vi.stubGlobal("fetch", browserFetch); + await seedPendingOAuth(undefined); + authFetch.mockResolvedValueOnce( + createJsonResponse({ + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + body: { + access_token: "proxied-access-token", + refresh_token: "proxied-refresh-token", + token_type: "Bearer", + }, + }), + ); + mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { + const response = await options!.fetchFn!( + "https://app.asana.com/-/oauth_token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + return await response.json(); + }); + + const { handleOAuthCallback } = await import("../mcp-oauth"); + const callbackResult = await handleOAuthCallback("oauth-code"); + + expect(callbackResult.success).toBe(true); + expect(browserFetch).not.toHaveBeenCalled(); + expect(authFetch).toHaveBeenCalledWith( + "/api/mcp/oauth/proxy", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://app.asana.com/-/oauth_token", + method: "POST", + headers: {}, + body: { + grant_type: "authorization_code", + code: "oauth-code", + code_verifier: "test-verifier", + redirect_uri: `${window.location.origin}/oauth/callback`, + }, + }), + }), + ); + }); }); }); diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index cd248ee77..80d858c89 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -2,7 +2,13 @@ * Clean OAuth implementation using only the official MCP SDK with CORS proxy support */ -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { + auth, + discoverAuthorizationServerMetadata, + discoverOAuthServerInfo, + fetchToken, + selectResourceURL, +} from "@modelcontextprotocol/sdk/client/auth.js"; import type { OAuthClientProvider, OAuthDiscoveryState, @@ -42,6 +48,8 @@ function clearStoredDiscoveryState(serverName: string): void { localStorage.removeItem(getDiscoveryStorageKey(serverName)); } +type OAuthRequestFields = Record; + function getStoredRegistryServerId( serverName: string | null, ): string | undefined { @@ -55,6 +63,124 @@ function getStoredRegistryServerId( } } +function parseOAuthRequestFields(body: unknown): OAuthRequestFields | undefined { + if (!body) return undefined; + + if (typeof body === "string") { + const params = new URLSearchParams(body); + const entries = Object.fromEntries(params.entries()); + return Object.keys(entries).length > 0 ? entries : undefined; + } + + if (typeof body !== "object" || body === null || Array.isArray(body)) { + return undefined; + } + + const entries = Object.entries(body).flatMap(([key, value]) => { + if (typeof value === "string") { + return [[key, value] as const]; + } + if (typeof value === "number" || typeof value === "boolean") { + return [[key, String(value)] as const]; + } + return []; + }); + + return entries.length > 0 ? Object.fromEntries(entries) : undefined; +} + +function getOAuthGrantType(body: unknown): string | undefined { + return parseOAuthRequestFields(body)?.grant_type; +} + +function isRegistryTokenGrantRequest( + registryServerId: string | undefined, + method: string, + body: unknown, +): body is OAuthRequestFields { + if (!registryServerId || method !== "POST") { + return false; + } + + const grantType = getOAuthGrantType(body); + return grantType === "authorization_code" || grantType === "refresh_token"; +} + +function toConvexOAuthPayload( + registryServerId: string, + fields: OAuthRequestFields, +): Record { + const payload: Record = { + registryServerId, + ...fields, + }; + + if (fields.grant_type) { + payload.grantType = fields.grant_type; + } + if (fields.redirect_uri) { + payload.redirectUri = fields.redirect_uri; + } + if (fields.code_verifier) { + payload.codeVerifier = fields.code_verifier; + } + if (fields.refresh_token) { + payload.refreshToken = fields.refresh_token; + } + if (fields.client_id) { + payload.clientId = fields.client_id; + } + if (fields.client_secret) { + payload.clientSecret = fields.client_secret; + } + + return payload; +} + +function logOAuthErrorResponse(prefix: string, response: Response): void { + response + .clone() + .text() + .then((body) => { + console.log(prefix, response.status, body || "(empty)"); // ##TODOClean + }) + .catch((error) => { + console.log(prefix, response.status, "failed to read body", error); // ##TODOClean + }); +} + +async function loadCallbackDiscoveryState( + provider: MCPOAuthProvider, + serverUrl: string, + fetchFn: typeof fetch, +): Promise { + const cachedState = await provider.discoveryState(); + if (cachedState?.authorizationServerUrl) { + const authorizationServerMetadata = + cachedState.authorizationServerMetadata ?? + (await discoverAuthorizationServerMetadata( + cachedState.authorizationServerUrl, + { fetchFn }, + )); + + const discoveryState: OAuthDiscoveryState = { + ...cachedState, + authorizationServerMetadata, + }; + await provider.saveDiscoveryState(discoveryState); + return discoveryState; + } + + const discovered = await discoverOAuthServerInfo(serverUrl, { fetchFn }); + const discoveryState: OAuthDiscoveryState = { + authorizationServerUrl: discovered.authorizationServerUrl, + resourceMetadata: discovered.resourceMetadata, + authorizationServerMetadata: discovered.authorizationServerMetadata, + }; + await provider.saveDiscoveryState(discoveryState); + return discoveryState; +} + /** * Custom fetch interceptor that proxies OAuth requests through our server to avoid CORS. * When a registryServerId is provided, token exchange/refresh is routed through @@ -65,46 +191,78 @@ function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { input: RequestInfo | URL, init?: RequestInit, ): Promise { + const method = (init?.method || "GET").toUpperCase(); + const serializedBody = init?.body ? await serializeBody(init.body) : undefined; const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const oauthGrantType = getOAuthGrantType(serializedBody); + const isRegistryTokenRequest = isRegistryTokenGrantRequest( + registryServerId, + method, + serializedBody, + ); // Check if this is an OAuth-related request that needs CORS bypass const isOAuthRequest = url.includes("/.well-known/") || - url.match(/\/(register|token|authorize)$/); + url.match(/\/(register|token|authorize)$/) || + oauthGrantType === "authorization_code" || + oauthGrantType === "refresh_token"; if (!isOAuthRequest) { return await originalFetch(input, init); } // For registry servers, route token exchange/refresh through Convex HTTP actions - if (registryServerId) { - const isTokenRequest = url.match(/\/token$/); - if (isTokenRequest) { - const convexSiteUrl = getConvexSiteUrl(); - if (convexSiteUrl) { - const body = init?.body ? await serializeBody(init.body) : undefined; - const isRefresh = - typeof body === "object" && - body !== null && - (body as any).grant_type === "refresh_token"; - const endpoint = isRefresh + console.log( + "[OAuthDebug] interceptedFetch:", + url, + "registryServerId:", + registryServerId, + "method:", + method, + "grantType:", + oauthGrantType, + "isOAuth:", + isOAuthRequest, + "isRegistryTokenRequest:", + isRegistryTokenRequest, + ); // ##TODOClean + if (isRegistryTokenRequest) { + const convexSiteUrl = getConvexSiteUrl(); + if (convexSiteUrl) { + const endpoint = + serializedBody.grant_type === "refresh_token" ? "/registry/oauth/refresh" : "/registry/oauth/token"; - const response = await originalFetch(`${convexSiteUrl}${endpoint}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - registryServerId, - ...(typeof body === "object" && body !== null ? body : {}), - }), - }); - return response; + console.log( + "[OAuthDebug] INTERCEPTING token request → routing to Convex", + endpoint, + ); // ##TODOClean + const response = await originalFetch(`${convexSiteUrl}${endpoint}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + toConvexOAuthPayload(registryServerId, serializedBody), + ), + }); + console.log( + "[OAuthDebug] Convex OAuth route status:", + response.status, + "endpoint:", + endpoint, + ); // ##TODOClean + if (!response.ok) { + logOAuthErrorResponse( + "[OAuthDebug] Convex OAuth route error:", + response, + ); } + return response; } } @@ -121,22 +279,22 @@ function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { } // For OAuth endpoints, serialize and proxy the full request - const body = init?.body ? await serializeBody(init.body) : undefined; const response = await authFetch(proxyUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url, - method: init?.method || "POST", + method, headers: init?.headers ? Object.fromEntries(new Headers(init.headers as HeadersInit)) : {}, - body, + body: serializedBody, }), }); // If the proxy call itself failed (e.g., auth error), return that response directly if (!response.ok) { + logOAuthErrorResponse("[OAuthDebug] OAuth proxy error:", response); return response; } @@ -157,7 +315,9 @@ function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { * Serialize request body for proxying */ async function serializeBody(body: BodyInit): Promise { - if (typeof body === "string") return body; + if (typeof body === "string") { + return parseOAuthRequestFields(body) ?? body; + } if (body instanceof URLSearchParams || body instanceof FormData) { return Object.fromEntries(body.entries()); } @@ -313,6 +473,7 @@ export class MCPOAuthProvider implements OAuthClientProvider { captureServerDetailModalOAuthResume(this.serverName); // Store server name for callback recovery localStorage.setItem("mcp-oauth-pending", this.serverName); + console.log("[OAuthDebug] SET mcp-oauth-pending =", this.serverName, "(redirectToAuthorization)"); // ##TODOClean // Store current hash to restore after OAuth callback if (window.location.hash) { localStorage.setItem("mcp-oauth-return-hash", window.location.hash); @@ -322,10 +483,12 @@ export class MCPOAuthProvider implements OAuthClientProvider { async saveCodeVerifier(codeVerifier: string) { localStorage.setItem(`mcp-verifier-${this.serverName}`, codeVerifier); + console.log("[OAuthDebug] SAVED verifier for", this.serverName); // ##TODOClean } codeVerifier(): string { const verifier = localStorage.getItem(`mcp-verifier-${this.serverName}`); + console.log("[OAuthDebug] READ verifier for", this.serverName, "exists:", !!verifier); // ##TODOClean if (!verifier) { throw new Error("Code verifier not found"); } @@ -335,6 +498,7 @@ export class MCPOAuthProvider implements OAuthClientProvider { async invalidateCredentials( scope: "all" | "client" | "tokens" | "verifier" | "discovery", ) { + console.log("[OAuthDebug] invalidateCredentials:", scope, "for", this.serverName, new Error().stack); // ##TODOClean switch (scope) { case "all": localStorage.removeItem(`mcp-tokens-${this.serverName}`); @@ -364,11 +528,8 @@ export class MCPOAuthProvider implements OAuthClientProvider { export async function initiateOAuth( options: MCPOAuthOptions, ): Promise { - // Install fetch interceptor for OAuth metadata requests - const interceptedFetch = createOAuthFetchInterceptor( - options.registryServerId, - ); - window.fetch = interceptedFetch; + // Build fetch interceptor — routes token requests through Convex for registry servers + const fetchFn = createOAuthFetchInterceptor(options.registryServerId); try { const provider = new MCPOAuthProvider( @@ -384,6 +545,7 @@ export async function initiateOAuth( options.serverUrl, ); localStorage.setItem("mcp-oauth-pending", options.serverName); + console.log("[OAuthDebug] SET mcp-oauth-pending =", options.serverName, "registryServerId:", options.registryServerId, "clientId:", options.clientId, "(initiateOAuth)"); // ##TODOClean // Store OAuth configuration (scopes, registryServerId) for recovery if connection fails const oauthConfig: any = {}; @@ -421,7 +583,7 @@ export async function initiateOAuth( ); } - const authArgs: any = { serverUrl: options.serverUrl }; + const authArgs: any = { serverUrl: options.serverUrl, fetchFn }; if (options.scopes && options.scopes.length > 0) { authArgs.scope = options.scopes.join(" "); } @@ -488,13 +650,14 @@ export async function handleOAuthCallback( ): Promise { // Get pending server name from localStorage (needed before creating interceptor) const serverName = localStorage.getItem("mcp-oauth-pending"); + console.log("[OAuthDebug] handleOAuthCallback: mcp-oauth-pending =", serverName); // ##TODOClean // Read registryServerId from stored OAuth config if present const registryServerId = getStoredRegistryServerId(serverName); + console.log("[OAuthDebug] handleOAuthCallback: registryServerId =", registryServerId, "oauthConfig =", localStorage.getItem(`mcp-oauth-config-${serverName}`)); // ##TODOClean - // Install fetch interceptor for OAuth metadata requests - const interceptedFetch = createOAuthFetchInterceptor(registryServerId); - window.fetch = interceptedFetch; + // Build fetch interceptor — routes token requests through Convex for registry servers + const fetchFn = createOAuthFetchInterceptor(registryServerId); try { if (!serverName) { @@ -522,30 +685,40 @@ export async function handleOAuthCallback( customClientId, customClientSecret, ); - - const result = await auth(provider, { + const discoveryState = await loadCallbackDiscoveryState( + provider, + serverUrl, + fetchFn, + ); + const resource = await selectResourceURL( serverUrl, + provider, + discoveryState.resourceMetadata, + ); + console.log( + "[OAuthDebug] callback token exchange target:", + discoveryState.authorizationServerMetadata?.token_endpoint ?? + `${discoveryState.authorizationServerUrl}/token`, + "resource:", + resource?.toString() ?? "(none)", + ); // ##TODOClean + const tokens = await fetchToken(provider, discoveryState.authorizationServerUrl, { + metadata: discoveryState.authorizationServerMetadata, + resource, authorizationCode, + fetchFn, }); + await provider.saveTokens(tokens); - if (result === "AUTHORIZED") { - const tokens = provider.tokens(); - if (tokens) { - // Clean up pending state - localStorage.removeItem("mcp-oauth-pending"); - - const serverConfig = createServerConfig(serverUrl, tokens); - return { - success: true, - serverConfig, - serverName, // Return server name so caller doesn't need to look it up - }; - } - } + // Clean up pending state + console.log("[OAuthDebug] REMOVE mcp-oauth-pending (handleOAuthCallback success)"); // ##TODOClean + localStorage.removeItem("mcp-oauth-pending"); + const serverConfig = createServerConfig(serverUrl, tokens); return { - success: false, - error: "Token exchange failed", + success: true, + serverConfig, + serverName, // Return server name so caller doesn't need to look it up }; } catch (error) { let errorMessage = "Unknown callback error"; @@ -662,12 +835,9 @@ export async function waitForTokens( export async function refreshOAuthTokens( serverName: string, ): Promise { - // Read registryServerId from stored OAuth config if present + // Build fetch interceptor — routes token requests through Convex for registry servers const registryServerId = getStoredRegistryServerId(serverName); - - // Install fetch interceptor for OAuth metadata requests - const interceptedFetch = createOAuthFetchInterceptor(registryServerId); - window.fetch = interceptedFetch; + const fetchFn = createOAuthFetchInterceptor(registryServerId); try { // Get stored client credentials if any @@ -703,7 +873,7 @@ export async function refreshOAuthTokens( }; } - const result = await auth(provider, { serverUrl }); + const result = await auth(provider, { serverUrl, fetchFn }); if (result === "AUTHORIZED") { const tokens = provider.tokens(); @@ -756,6 +926,7 @@ export async function refreshOAuthTokens( * Clears all OAuth data for a server */ export function clearOAuthData(serverName: string): void { + console.log("[OAuthDebug] clearOAuthData:", serverName, new Error().stack); // ##TODOClean localStorage.removeItem(`mcp-tokens-${serverName}`); localStorage.removeItem(`mcp-client-${serverName}`); localStorage.removeItem(`mcp-verifier-${serverName}`); diff --git a/mcpjam-inspector/client/src/state/oauth-orchestrator.ts b/mcpjam-inspector/client/src/state/oauth-orchestrator.ts index 0ca0114f2..f97b9d618 100644 --- a/mcpjam-inspector/client/src/state/oauth-orchestrator.ts +++ b/mcpjam-inspector/client/src/state/oauth-orchestrator.ts @@ -74,6 +74,7 @@ export async function ensureAuthorizedForReconnect( clientSecret: server.oauthTokens?.client_secret || clientInfo?.client_secret, scopes: oauthConfig.scopes, + registryServerId: oauthConfig.registryServerId, } as MCPOAuthOptions; const init = await initiateOAuth(opts); if (init.success && init.serverConfig) { From a39bcfbac030bef5e3bbfe5dc92d8a8a68acc051 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Tue, 24 Mar 2026 13:55:15 -0700 Subject: [PATCH 08/21] fix tests --- .../mcp-sidebar-feature-flags.test.ts | 30 +++++++++++ .../client/src/components/mcp-sidebar.tsx | 21 +++++++- .../src/hooks/__tests__/useWorkspaces.test.ts | 39 ++++++++++++++ .../client/src/hooks/useWorkspaces.ts | 54 ++++++++++++++----- .../src/lib/oauth/__tests__/mcp-oauth.test.ts | 40 ++++++-------- .../client/src/lib/oauth/mcp-oauth.ts | 2 +- 6 files changed, 148 insertions(+), 38 deletions(-) diff --git a/mcpjam-inspector/client/src/components/__tests__/mcp-sidebar-feature-flags.test.ts b/mcpjam-inspector/client/src/components/__tests__/mcp-sidebar-feature-flags.test.ts index a44ef07b0..b9244afdc 100644 --- a/mcpjam-inspector/client/src/components/__tests__/mcp-sidebar-feature-flags.test.ts +++ b/mcpjam-inspector/client/src/components/__tests__/mcp-sidebar-feature-flags.test.ts @@ -3,6 +3,7 @@ import { filterByBillingEntitlements, filterByFeatureFlags, getHostedNavigationSections, + shouldPrefetchSidebarTools, } from "../mcp-sidebar"; import { HOSTED_LOCAL_ONLY_TOOLTIP } from "@/lib/hosted-ui"; @@ -211,3 +212,32 @@ describe("getHostedNavigationSections", () => { ]); }); }); + +describe("shouldPrefetchSidebarTools", () => { + it("skips sidebar tool prefetch for hosted guests", () => { + expect( + shouldPrefetchSidebarTools({ + hostedMode: true, + isAuthenticated: false, + }), + ).toBe(false); + }); + + it("allows sidebar tool prefetch for hosted signed-in users", () => { + expect( + shouldPrefetchSidebarTools({ + hostedMode: true, + isAuthenticated: true, + }), + ).toBe(true); + }); + + it("allows sidebar tool prefetch outside hosted mode", () => { + expect( + shouldPrefetchSidebarTools({ + hostedMode: false, + isAuthenticated: false, + }), + ).toBe(true); + }); +}); diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx index ee7b62571..40a356143 100644 --- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx +++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx @@ -113,6 +113,17 @@ export function filterByBillingEntitlements( .filter((section) => section.items.length > 0); } +export function shouldPrefetchSidebarTools(options: { + hostedMode: boolean; + isAuthenticated: boolean; +}): boolean { + const { hostedMode, isAuthenticated } = options; + // Hosted guests can briefly hydrate stale "connected" local servers before + // runtime status sync clears them, which causes speculative tools/list calls + // against guest server configs. Only signed-in hosted users should prefetch. + return !hostedMode || isAuthenticated; +} + // Define sections with their respective items const navigationSections: NavSection[] = [ { @@ -340,7 +351,13 @@ export function MCPSidebar({ // Fetch tools data for connected servers useEffect(() => { const fetchToolsData = async () => { - if (connectedServerNames.length === 0) { + if ( + !shouldPrefetchSidebarTools({ + hostedMode: HOSTED_MODE, + isAuthenticated, + }) || + connectedServerNames.length === 0 + ) { setToolsDataMap({}); return; } @@ -365,7 +382,7 @@ export function MCPSidebar({ }; fetchToolsData(); - }, [connectedServerNames.join(",")]); + }, [connectedServerNames.join(","), isAuthenticated]); // Check if any connected server is an app const hasAppServer = useMemo(() => { diff --git a/mcpjam-inspector/client/src/hooks/__tests__/useWorkspaces.test.ts b/mcpjam-inspector/client/src/hooks/__tests__/useWorkspaces.test.ts index 8043ad317..874cd4194 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/useWorkspaces.test.ts +++ b/mcpjam-inspector/client/src/hooks/__tests__/useWorkspaces.test.ts @@ -2,8 +2,10 @@ import { renderHook } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { filterWorkspacesForOrganization, + normalizeWorkspaceMembersResult, type RemoteWorkspace, useWorkspaceQueries, + type WorkspaceMember, } from "../useWorkspaces"; const { mockUseMutation, mockUseQuery } = vi.hoisted(() => ({ @@ -91,3 +93,40 @@ describe("useWorkspaceQueries", () => { expect(mockUseQuery).toHaveBeenCalledWith("workspaces:getMyWorkspaces", {}); }); }); + +describe("normalizeWorkspaceMembersResult", () => { + it("supports the current object-shaped backend response", () => { + const member: WorkspaceMember = { + _id: "member-1", + workspaceId: "ws-1", + email: "person@example.com", + addedBy: "user-1", + addedAt: 1, + isOwner: false, + isPending: false, + hasAccess: true, + accessSource: "workspace", + canRemove: true, + user: null, + }; + + expect( + normalizeWorkspaceMembersResult({ + members: [member], + canManageMembers: true, + }), + ).toEqual({ + members: [member], + canManageMembers: true, + }); + }); + + it("falls back safely for unexpected query values", () => { + expect( + normalizeWorkspaceMembersResult("billing_limit_reached" as never), + ).toEqual({ + members: [], + canManageMembers: false, + }); + }); +}); diff --git a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts index 56bcc6ddf..da5a9b61c 100644 --- a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts +++ b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts @@ -73,6 +73,38 @@ export interface WorkspaceMember { } | null; } +interface WorkspaceMembersQueryResult { + members: WorkspaceMember[]; + canManageMembers: boolean; +} + +function isWorkspaceMembersQueryResult( + value: unknown, +): value is WorkspaceMembersQueryResult { + return ( + typeof value === "object" && + value !== null && + Array.isArray((value as WorkspaceMembersQueryResult).members) + ); +} + +export function normalizeWorkspaceMembersResult( + value: WorkspaceMember[] | WorkspaceMembersQueryResult | undefined, +): WorkspaceMembersQueryResult { + if (Array.isArray(value)) { + return { members: value, canManageMembers: false }; + } + + if (isWorkspaceMembersQueryResult(value)) { + return { + members: value.members, + canManageMembers: value.canManageMembers, + }; + } + + return { members: [], canManageMembers: false }; +} + export function filterWorkspacesForOrganization( workspaces: RemoteWorkspace[] | undefined, organizationId?: string, @@ -136,20 +168,17 @@ export function useWorkspaceMembers({ }) { const enableQuery = isAuthenticated && !!workspaceId; - const raw = useQuery( + const membersResult = useQuery( "workspaces:getWorkspaceMembers" as any, enableQuery ? ({ workspaceId } as any) : "skip", - ) as - | { members: WorkspaceMember[]; canManageMembers: boolean } - | WorkspaceMember[] - | undefined; - - // Server returns `{ members, canManageMembers }`. Legacy deployments returned a bare array. - const members = Array.isArray(raw) ? raw : raw?.members; - const canManageMembers = Array.isArray(raw) - ? false - : (raw?.canManageMembers ?? false); - const isLoading = enableQuery && raw === undefined; + ) as WorkspaceMember[] | WorkspaceMembersQueryResult | undefined; + + const isLoading = enableQuery && membersResult === undefined; + + const { members, canManageMembers } = useMemo( + () => normalizeWorkspaceMembersResult(membersResult), + [membersResult], + ); const activeMembers = useMemo(() => { if (!members) return []; @@ -168,6 +197,7 @@ export function useWorkspaceMembers({ canManageMembers, isLoading, hasPendingMembers: pendingMembers.length > 0, + canManageMembers, }; } diff --git a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts index 040a1c6d6..6288412f5 100644 --- a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts +++ b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts @@ -203,8 +203,8 @@ describe("mcp-oauth", () => { { status: 200 }, ); authFetch.mockResolvedValue(metadataResponse); - mockSdkAuth.mockImplementation(async () => { - const response = await window.fetch( + mockSdkAuth.mockImplementation(async (_provider: any, options: any) => { + const response = await options.fetchFn( "https://example.com/.well-known/oauth-protected-resource/mcp", ); expect(response.ok).toBe(true); @@ -355,8 +355,7 @@ describe("mcp-oauth", () => { }); it("routes Asana-style callback token exchange through Convex for registry servers", async () => { - vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); - const browserFetch = vi.fn(async (input: RequestInfo | URL) => { + authFetch.mockImplementationOnce(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input @@ -364,7 +363,7 @@ describe("mcp-oauth", () => { ? input.toString() : input.url; - if (url === "https://example.convex.site/registry/oauth/token") { + if (url.includes("/registry/oauth/token")) { return createJsonResponse({ access_token: "access-token", refresh_token: "refresh-token", @@ -374,7 +373,6 @@ describe("mcp-oauth", () => { throw new Error(`Unexpected direct fetch to ${url}`); }); - vi.stubGlobal("fetch", browserFetch); const discoveryState = createAsanaDiscoveryState(); await seedPendingOAuth("registry-asana", discoveryState); @@ -402,29 +400,28 @@ describe("mcp-oauth", () => { const callbackResult = await handleOAuthCallback("oauth-code"); expect(callbackResult.success).toBe(true); - expect(browserFetch).toHaveBeenCalledTimes(1); - expect(browserFetch).toHaveBeenCalledWith( - "https://example.convex.site/registry/oauth/token", + expect(authFetch).toHaveBeenCalledTimes(1); + expect(authFetch).toHaveBeenCalledWith( + expect.stringMatching(/\.convex\.site\/registry\/oauth\/token$/), expect.objectContaining({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ registryServerId: "registry-asana", grant_type: "authorization_code", - grantType: "authorization_code", code: "oauth-code", code_verifier: "test-verifier", - codeVerifier: "test-verifier", redirect_uri: `${window.location.origin}/oauth/callback`, + grantType: "authorization_code", redirectUri: `${window.location.origin}/oauth/callback`, + codeVerifier: "test-verifier", }), }), ); }); it("routes Asana-style refresh token exchange through Convex for registry servers", async () => { - vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); - const browserFetch = vi.fn(async (input: RequestInfo | URL) => { + authFetch.mockImplementationOnce(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input @@ -432,7 +429,7 @@ describe("mcp-oauth", () => { ? input.toString() : input.url; - if (url === "https://example.convex.site/registry/oauth/refresh") { + if (url.includes("/registry/oauth/refresh")) { return createJsonResponse({ access_token: "new-access-token", refresh_token: "new-refresh-token", @@ -442,7 +439,6 @@ describe("mcp-oauth", () => { throw new Error(`Unexpected direct fetch to ${url}`); }); - vi.stubGlobal("fetch", browserFetch); mockSdkAuth.mockImplementationOnce(async (_provider, options) => { const response = await options.fetchFn!( @@ -478,17 +474,17 @@ describe("mcp-oauth", () => { const refreshResult = await refreshOAuthTokens("asana"); expect(refreshResult.success).toBe(true); - expect(browserFetch).toHaveBeenCalledTimes(1); - expect(browserFetch).toHaveBeenCalledWith( - "https://example.convex.site/registry/oauth/refresh", + expect(authFetch).toHaveBeenCalledTimes(1); + expect(authFetch).toHaveBeenCalledWith( + expect.stringMatching(/\.convex\.site\/registry\/oauth\/refresh$/), expect.objectContaining({ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ registryServerId: "registry-asana", grant_type: "refresh_token", - grantType: "refresh_token", refresh_token: "stored-refresh-token", + grantType: "refresh_token", refreshToken: "stored-refresh-token", }), }), @@ -496,8 +492,7 @@ describe("mcp-oauth", () => { }); it("preserves the original callback error and verifier when registry token exchange fails", async () => { - vi.stubEnv("VITE_CONVEX_SITE_URL", "https://example.convex.site"); - const browserFetch = vi.fn(async (input: RequestInfo | URL) => { + authFetch.mockImplementationOnce(async (input: RequestInfo | URL) => { const url = typeof input === "string" ? input @@ -505,7 +500,7 @@ describe("mcp-oauth", () => { ? input.toString() : input.url; - if (url === "https://example.convex.site/registry/oauth/token") { + if (url.includes("/registry/oauth/token")) { return createJsonResponse( { error: "invalid_client", @@ -517,7 +512,6 @@ describe("mcp-oauth", () => { throw new Error(`Unexpected direct fetch to ${url}`); }); - vi.stubGlobal("fetch", browserFetch); await seedPendingOAuth("registry-asana"); mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index 80d858c89..67228c074 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -243,7 +243,7 @@ function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { "[OAuthDebug] INTERCEPTING token request → routing to Convex", endpoint, ); // ##TODOClean - const response = await originalFetch(`${convexSiteUrl}${endpoint}`, { + const response = await authFetch(`${convexSiteUrl}${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify( From 4d957ed732c9f9cbc80914b2d4762fdfbf3a52a0 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Tue, 24 Mar 2026 14:24:52 -0700 Subject: [PATCH 09/21] fix disconnect --- .../client/src/components/RegistryTab.tsx | 20 ++- .../components/__tests__/RegistryTab.test.tsx | 63 +++++++++ .../__tests__/useRegistryServers.test.tsx | 127 ++++++++++++++++++ .../client/src/hooks/useRegistryServers.ts | 32 ++++- 4 files changed, 230 insertions(+), 12 deletions(-) create mode 100644 mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index f02582acb..0fdcc77e5 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -26,6 +26,7 @@ import { import { useRegistryServers, consolidateServers, + getRegistryServerName, type EnrichedRegistryServer, type ConsolidatedRegistryServer, type RegistryConnectionStatus, @@ -69,7 +70,13 @@ export function RegistryTab({ if (!onNavigate) return; const pending = localStorage.getItem("registry-pending-redirect"); if (!pending) return; - const liveServer = servers?.[pending]; + const liveServer = + servers?.[pending] ?? + Object.entries(servers ?? {}).find( + ([name, server]) => + server.connectionStatus === "connected" && + name.startsWith(`${pending} (`), + )?.[1]; if (liveServer?.connectionStatus === "connected") { localStorage.removeItem("registry-pending-redirect"); onNavigate("app-builder"); @@ -83,13 +90,13 @@ export function RegistryTab({ const handleConnect = async (server: EnrichedRegistryServer) => { setConnectingIds((prev) => new Set(prev).add(server._id)); - localStorage.setItem("registry-pending-redirect", server.displayName); + const serverName = getRegistryServerName(server); + localStorage.setItem("registry-pending-redirect", serverName); try { await connect(server); } catch (error) { - if ( - localStorage.getItem("registry-pending-redirect") === server.displayName - ) { + const pending = localStorage.getItem("registry-pending-redirect"); + if (pending === serverName || pending === server.displayName) { localStorage.removeItem("registry-pending-redirect"); } throw error; @@ -103,8 +110,9 @@ export function RegistryTab({ }; const handleDisconnect = async (server: EnrichedRegistryServer) => { + const serverName = getRegistryServerName(server); const pending = localStorage.getItem("registry-pending-redirect"); - if (pending === server.displayName) { + if (pending === serverName || pending === server.displayName) { localStorage.removeItem("registry-pending-redirect"); } await disconnect(server); diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index 38f19b9b7..b569321ab 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -458,6 +458,45 @@ describe("RegistryTab", () => { }); expect(localStorage.getItem("registry-pending-redirect")).toBeNull(); }); + + it("redirects when a legacy pending display name matches a suffixed connected variant", async () => { + localStorage.setItem("registry-pending-redirect", "Asana"); + + const server = createMockServer({ + displayName: "Asana", + clientType: "app" as any, + }); + mockHookReturn = { + registryServers: [server], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + const onNavigate = vi.fn(); + + render( + , + ); + + await waitFor(() => { + expect(onNavigate).toHaveBeenCalledWith("app-builder"); + }); + expect(localStorage.getItem("registry-pending-redirect")).toBeNull(); + }); }); describe("consolidated cards — dual-type servers", () => { @@ -578,5 +617,29 @@ describe("RegistryTab", () => { expect(itemTexts.some((t) => t?.includes("Text"))).toBe(true); expect(itemTexts.some((t) => t?.includes("App"))).toBe(true); }); + + it("stores the suffixed runtime name when connecting a dual-type variant", async () => { + mockHookReturn = { + registryServers: [ + createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), + createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), + ], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + fireEvent.click(screen.getByText("Connect as App")); + + await waitFor(() => { + expect(mockConnect).toHaveBeenCalled(); + }); + expect(localStorage.getItem("registry-pending-redirect")).toBe( + "Asana (App)", + ); + }); }); }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx new file mode 100644 index 000000000..4fdd9c1a6 --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx @@ -0,0 +1,127 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + getRegistryServerName, + type RegistryServer, + useRegistryServers, +} from "../useRegistryServers"; + +const { + mockUseQuery, + mockConnectMutation, + mockDisconnectMutation, +} = vi.hoisted(() => ({ + mockUseQuery: vi.fn(), + mockConnectMutation: vi.fn(), + mockDisconnectMutation: vi.fn(), +})); + +vi.mock("convex/react", () => ({ + useQuery: (...args: unknown[]) => mockUseQuery(...args), + useMutation: (name: string) => { + if (name === "registryServers:connectRegistryServer") { + return mockConnectMutation; + } + if (name === "registryServers:disconnectRegistryServer") { + return mockDisconnectMutation; + } + return vi.fn(); + }, +})); + +function createRegistryServer( + overrides: Partial = {}, +): RegistryServer { + return { + _id: "server-1", + name: "com.test.asana", + displayName: "Asana", + description: "Asana MCP server", + publisher: "MCPJam", + category: "Productivity", + clientType: "app", + scope: "global", + transport: { + transportType: "http", + url: "https://mcp.asana.test", + useOAuth: true, + }, + status: "approved", + createdBy: "user-1", + createdAt: Date.now(), + updatedAt: Date.now(), + ...overrides, + }; +} + +describe("useRegistryServers", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseQuery.mockImplementation((name: string) => { + if (name === "registryServers:listRegistryServers") { + return [createRegistryServer()]; + } + if (name === "registryServers:getWorkspaceRegistryConnections") { + return []; + } + return undefined; + }); + }); + + it("disconnects app variants using the runtime server name", async () => { + const onDisconnect = vi.fn(); + const server = createRegistryServer({ clientType: "app" }); + + const { result } = renderHook(() => + useRegistryServers({ + workspaceId: "workspace-1", + isAuthenticated: true, + liveServers: { + [getRegistryServerName(server)]: { + connectionStatus: "connected", + }, + }, + onConnect: vi.fn(), + onDisconnect, + }), + ); + + await act(async () => { + await result.current.disconnect(server); + }); + + expect(onDisconnect).toHaveBeenCalledWith("Asana (App)"); + expect(mockDisconnectMutation).toHaveBeenCalledWith({ + registryServerId: "server-1", + workspaceId: "workspace-1", + }); + }); + + it("still disconnects locally when the workspace connection is already missing", async () => { + const onDisconnect = vi.fn(); + const server = createRegistryServer({ clientType: "app" }); + mockDisconnectMutation.mockRejectedValueOnce( + new Error("Registry server is not connected to this workspace"), + ); + + const { result } = renderHook(() => + useRegistryServers({ + workspaceId: "workspace-1", + isAuthenticated: true, + liveServers: { + [getRegistryServerName(server)]: { + connectionStatus: "connected", + }, + }, + onConnect: vi.fn(), + onDisconnect, + }), + ); + + await act(async () => { + await expect(result.current.disconnect(server)).resolves.toBeUndefined(); + }); + + expect(onDisconnect).toHaveBeenCalledWith("Asana (App)"); + }); +}); diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index 855160d1a..6d73a2eb4 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -286,12 +286,19 @@ export function consolidateServers( /** * Returns the server name that matches what Convex creates (with (App)/(Text) suffix). */ -function getRegistryServerName(server: RegistryServer): string { +export function getRegistryServerName(server: RegistryServer): string { if (server.clientType === "app") return `${server.displayName} (App)`; if (server.clientType === "text") return `${server.displayName} (Text)`; return server.displayName; } +function isMissingWorkspaceConnectionError(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes("Registry server is not connected to this workspace") + ); +} + /** * Hook for fetching registry servers and managing connections. * @@ -430,16 +437,29 @@ export function useRegistryServers({ } async function disconnect(server: RegistryServer) { + const serverName = getRegistryServerName(server); + let disconnectError: unknown; + // 1. Remove the connection from Convex (only when authenticated with a workspace) if (!DEV_MOCK_REGISTRY && isAuthenticated && workspaceId) { - await disconnectMutation({ - registryServerId: server._id, - workspaceId, - } as any); + try { + await disconnectMutation({ + registryServerId: server._id, + workspaceId, + } as any); + } catch (error) { + if (!isMissingWorkspaceConnectionError(error)) { + disconnectError = error; + } + } } // 2. Trigger the local MCP disconnection - onDisconnect?.(server.displayName); + onDisconnect?.(serverName); + + if (disconnectError) { + throw disconnectError; + } } return { From 875557d710cad1ed22445f86316e8bae7fe6248f Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Tue, 24 Mar 2026 15:35:20 -0700 Subject: [PATCH 10/21] working asana prereg and linear dcr --- .../use-server-state.hosted-oauth.test.tsx | 9 + .../hooks/__tests__/use-server-state.test.tsx | 141 +++++- .../__tests__/useRegistryServers.test.tsx | 57 +++ .../client/src/hooks/use-server-state.ts | 76 +++- .../client/src/hooks/useRegistryServers.ts | 12 +- .../src/lib/oauth/__tests__/mcp-oauth.test.ts | 402 +++++++++++++++++- .../client/src/lib/oauth/mcp-oauth.ts | 200 +++++++-- .../client/src/state/oauth-orchestrator.ts | 7 +- mcpjam-inspector/client/vite.config.ts | 3 + mcpjam-inspector/shared/types.ts | 4 +- 10 files changed, 850 insertions(+), 61 deletions(-) diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.hosted-oauth.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.hosted-oauth.test.tsx index 86626b484..11b01e64e 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.hosted-oauth.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.hosted-oauth.test.tsx @@ -7,6 +7,7 @@ const { mockHandleOAuthCallback, mockListServers, mockUseServerMutations, + mockConvexQuery, toastSuccess, } = vi.hoisted(() => ({ mockHandleOAuthCallback: vi.fn(), @@ -16,9 +17,16 @@ const { updateServer: vi.fn(), deleteServer: vi.fn(), })), + mockConvexQuery: vi.fn(), toastSuccess: vi.fn(), })); +vi.mock("convex/react", () => ({ + useConvex: () => ({ + query: mockConvexQuery, + }), +})); + vi.mock("@/lib/config", () => ({ HOSTED_MODE: true, })); @@ -77,6 +85,7 @@ describe("useServerState hosted OAuth callback guards", () => { window.history.replaceState({}, "", "/?code=oauth-code"); mockHandleOAuthCallback.mockReset(); mockListServers.mockReset(); + mockConvexQuery.mockReset(); toastSuccess.mockReset(); mockListServers.mockResolvedValue({ success: true, servers: [] }); }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index 75e78d6d6..c6cbf6b39 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AppState, AppAction } from "@/state/app-types"; import { CLIENT_CONFIG_SYNC_PENDING_ERROR_MESSAGE } from "@/lib/client-config"; @@ -6,13 +6,25 @@ import type { WorkspaceClientConfig } from "@/lib/client-config"; import { useClientConfigStore } from "@/stores/client-config-store"; import { useServerState } from "../use-server-state"; -const { toastError, toastSuccess, handleOAuthCallbackMock } = vi.hoisted( - () => ({ - toastError: vi.fn(), - toastSuccess: vi.fn(), - handleOAuthCallbackMock: vi.fn(), - }), -); +const { + toastError, + toastSuccess, + handleOAuthCallbackMock, + initiateOAuthMock, + getStoredTokensMock, + clearOAuthDataMock, + testConnectionMock, + mockConvexQuery, +} = vi.hoisted(() => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), + handleOAuthCallbackMock: vi.fn(), + initiateOAuthMock: vi.fn(), + getStoredTokensMock: vi.fn(), + clearOAuthDataMock: vi.fn(), + testConnectionMock: vi.fn(), + mockConvexQuery: vi.fn(), +})); vi.mock("sonner", () => ({ toast: { @@ -21,8 +33,14 @@ vi.mock("sonner", () => ({ }, })); +vi.mock("convex/react", () => ({ + useConvex: () => ({ + query: mockConvexQuery, + }), +})); + vi.mock("@/state/mcp-api", () => ({ - testConnection: vi.fn(), + testConnection: testConnectionMock, deleteServer: vi.fn(), listServers: vi.fn(), reconnectServer: vi.fn(), @@ -35,9 +53,9 @@ vi.mock("@/state/oauth-orchestrator", () => ({ vi.mock("@/lib/oauth/mcp-oauth", () => ({ handleOAuthCallback: handleOAuthCallbackMock, - getStoredTokens: vi.fn(), - clearOAuthData: vi.fn(), - initiateOAuth: vi.fn(), + getStoredTokens: getStoredTokensMock, + clearOAuthData: clearOAuthDataMock, + initiateOAuth: initiateOAuthMock, })); vi.mock("@/lib/apis/web/context", () => ({ @@ -164,6 +182,13 @@ describe("useServerState OAuth callback failures", () => { pendingSavedConfig: undefined, isAwaitingRemoteEcho: false, }); + getStoredTokensMock.mockReturnValue(undefined); + testConnectionMock.mockResolvedValue({ + success: true, + initInfo: null, + }); + initiateOAuthMock.mockResolvedValue({ success: true }); + mockConvexQuery.mockResolvedValue(null); }); it("marks the pending server as failed when authorization is denied", async () => { @@ -301,4 +326,96 @@ describe("useServerState OAuth callback failures", () => { }, }); }); + + it("resolves preregistered registry OAuth config before initiating Asana connect", async () => { + mockConvexQuery.mockResolvedValueOnce({ + clientId: "asana-client-id", + scopes: ["default"], + }); + + const dispatch = vi.fn(); + const { result } = renderUseServerState(dispatch); + + await act(async () => { + await result.current.handleConnect({ + name: "Asana", + type: "http", + url: "https://mcp.asana.com/v2/mcp", + useOAuth: true, + registryServerId: "registry-asana", + oauthScopes: ["fallback-scope"], + }); + }); + + expect(mockConvexQuery).toHaveBeenCalledWith( + "registryServers:getRegistryServerOAuthConfig", + { registryServerId: "registry-asana" }, + ); + expect(initiateOAuthMock).toHaveBeenCalledWith({ + serverName: "Asana", + serverUrl: "https://mcp.asana.com/v2/mcp", + clientId: "asana-client-id", + clientSecret: undefined, + registryServerId: "registry-asana", + useRegistryOAuthProxy: true, + scopes: ["default"], + }); + }); + + it("keeps Linear registry OAuth on the generic path when no preregistered client ID is returned", async () => { + mockConvexQuery.mockResolvedValueOnce({ + scopes: ["read", "write"], + }); + + const dispatch = vi.fn(); + const { result } = renderUseServerState(dispatch); + + await act(async () => { + await result.current.handleConnect({ + name: "Linear", + type: "http", + url: "https://mcp.linear.app/mcp", + useOAuth: true, + registryServerId: "registry-linear", + oauthScopes: ["fallback-scope"], + }); + }); + + expect(initiateOAuthMock).toHaveBeenCalledWith({ + serverName: "Linear", + serverUrl: "https://mcp.linear.app/mcp", + clientId: undefined, + clientSecret: undefined, + registryServerId: "registry-linear", + useRegistryOAuthProxy: false, + scopes: ["read", "write"], + }); + }); + + it("fails registry OAuth initiation when the dedicated OAuth config query fails", async () => { + mockConvexQuery.mockRejectedValueOnce(new Error("registry lookup failed")); + + const dispatch = vi.fn(); + const { result } = renderUseServerState(dispatch); + + await act(async () => { + await result.current.handleConnect({ + name: "Asana", + type: "http", + url: "https://mcp.asana.com/v2/mcp", + useOAuth: true, + registryServerId: "registry-asana", + }); + }); + + expect(initiateOAuthMock).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ + type: "CONNECT_FAILURE", + name: "Asana", + error: "Failed to resolve registry OAuth config: registry lookup failed", + }); + expect(toastError).toHaveBeenCalledWith( + "Network error: Failed to resolve registry OAuth config: registry lookup failed", + ); + }); }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx index 4fdd9c1a6..fe4ba99c8 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx @@ -124,4 +124,61 @@ describe("useRegistryServers", () => { expect(onDisconnect).toHaveBeenCalledWith("Asana (App)"); }); + + it("does not create a duplicate workspace connection for an already connected registry server", async () => { + const server = createRegistryServer({ clientType: "app" }); + + mockUseQuery.mockImplementation((name: string) => { + if (name === "registryServers:listRegistryServers") { + return [server]; + } + if (name === "registryServers:getWorkspaceRegistryConnections") { + return [ + { + _id: "connection-1", + registryServerId: server._id, + workspaceId: "workspace-1", + serverId: "runtime-server-1", + connectedBy: "user-1", + connectedAt: Date.now(), + }, + ]; + } + return undefined; + }); + + const onConnect = vi.fn(); + const { result } = renderHook(() => + useRegistryServers({ + workspaceId: "workspace-1", + isAuthenticated: true, + liveServers: { + [getRegistryServerName(server)]: { + connectionStatus: "connected", + }, + }, + onConnect, + }), + ); + + await act(async () => { + await result.current.connect(server); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(onConnect).toHaveBeenCalledWith({ + name: "Asana (App)", + type: "http", + url: "https://mcp.asana.test", + useOAuth: true, + oauthScopes: undefined, + oauthCredentialKey: undefined, + clientId: undefined, + registryServerId: "server-1", + }); + expect(mockConnectMutation).not.toHaveBeenCalled(); + }); }); diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 194477fea..0576b091e 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, type Dispatch } from "react"; +import { useConvex } from "convex/react"; import { toast } from "sonner"; import type { HttpServerConfig, MCPServerConfig } from "@mcpjam/sdk/browser"; import type { @@ -57,6 +58,9 @@ function saveOAuthConfigToLocalStorage(formData: ServerFormData): void { if (formData.headers && Object.keys(formData.headers).length > 0) { oauthConfig.customHeaders = formData.headers; } + if (formData.registryServerId) { + oauthConfig.registryServerId = formData.registryServerId; + } if (Object.keys(oauthConfig).length > 0) { localStorage.setItem( `mcp-oauth-config-${formData.name}`, @@ -86,6 +90,19 @@ interface LoggerLike { debug: (message: string, meta?: Record) => void; } +interface RegistryOAuthConfigResponse { + clientId?: string; + scopes?: string[]; +} + +interface ResolvedOAuthInitiationInputs { + clientId?: string; + clientSecret?: string; + registryServerId?: string; + scopes?: string[]; + useRegistryOAuthProxy: boolean; +} + interface UseServerStateParams { appState: AppState; dispatch: Dispatch; @@ -118,6 +135,7 @@ export function useServerState({ activeWorkspaceServersFlat, logger, }: UseServerStateParams) { + const convex = useConvex(); const { createServer: convexCreateServer, updateServer: convexUpdateServer, @@ -474,6 +492,51 @@ export function useServerState({ [dispatch, fetchAndStoreInitInfo], ); + const resolveOAuthInitiationInputs = useCallback( + async ( + formData: ServerFormData, + ): Promise => { + let registryOAuthConfig: RegistryOAuthConfigResponse | null = null; + + if (formData.registryServerId) { + try { + registryOAuthConfig = (await convex.query( + "registryServers:getRegistryServerOAuthConfig" as any, + { registryServerId: formData.registryServerId } as any, + )) as RegistryOAuthConfigResponse | null; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + throw new Error( + `Failed to resolve registry OAuth config: ${errorMessage}`, + ); + } + } + + const clientId = + typeof registryOAuthConfig?.clientId === "string" && + registryOAuthConfig.clientId.trim() !== "" + ? registryOAuthConfig.clientId + : formData.clientId; + const scopes = + Array.isArray(registryOAuthConfig?.scopes) && + registryOAuthConfig.scopes.every( + (scope): scope is string => typeof scope === "string", + ) + ? registryOAuthConfig.scopes + : formData.oauthScopes; + + return { + clientId, + clientSecret: formData.clientSecret, + registryServerId: formData.registryServerId, + scopes, + useRegistryOAuthProxy: Boolean(clientId && formData.registryServerId), + }; + }, + [convex], + ); + const handleOAuthCallbackComplete = useCallback( async (code: string) => { const pendingServerName = localStorage.getItem("mcp-oauth-pending"); @@ -785,15 +848,17 @@ export function useServerState({ } as ServerWithName, }); + const oauthInputs = await resolveOAuthInitiationInputs(formData); const oauthOptions: any = { serverName: formData.name, serverUrl: formData.url, - clientId: formData.clientId, - clientSecret: formData.clientSecret, - registryServerId: formData.registryServerId, + clientId: oauthInputs.clientId, + clientSecret: oauthInputs.clientSecret, + registryServerId: oauthInputs.registryServerId, + useRegistryOAuthProxy: oauthInputs.useRegistryOAuthProxy, }; - if (formData.oauthScopes && formData.oauthScopes.length > 0) { - oauthOptions.scopes = formData.oauthScopes; + if (oauthInputs.scopes && oauthInputs.scopes.length > 0) { + oauthOptions.scopes = oauthInputs.scopes; } const oauthResult = await initiateOAuth(oauthOptions); if (oauthResult.success) { @@ -914,6 +979,7 @@ export function useServerState({ appState.workspaces, appState.activeWorkspaceId, notifyIfClientConfigSyncPending, + resolveOAuthInitiationInputs, syncServerToConvex, logger, storeInitInfo, diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index 6d73a2eb4..36a070e9b 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -1,4 +1,4 @@ -import { useMemo, useCallback, useState, useEffect } from "react"; +import { useMemo, useState, useEffect } from "react"; import { useQuery, useMutation } from "convex/react"; import type { ServerFormData } from "@/shared/types.js"; @@ -387,6 +387,15 @@ export function useRegistryServers({ useEffect(() => { if (!isAuthenticated || !workspaceId || DEV_MOCK_REGISTRY) return; for (const [registryServerId, serverName] of pendingServerIds) { + if (connectedRegistryIds.has(registryServerId)) { + setPendingServerIds((prev) => { + const next = new Map(prev); + next.delete(registryServerId); + return next; + }); + continue; + } + const liveServer = liveServers?.[serverName]; if (liveServer?.connectionStatus === "connected") { setPendingServerIds((prev) => { @@ -406,6 +415,7 @@ export function useRegistryServers({ isAuthenticated, workspaceId, connectMutation, + connectedRegistryIds, ]); const connectionsAreLoading = diff --git a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts index 6288412f5..1c01a8e11 100644 --- a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts +++ b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts @@ -72,6 +72,24 @@ function createAsanaDiscoveryState(): any { }; } +function createLinearDiscoveryState(): any { + return { + authorizationServerUrl: "https://mcp.linear.app", + resourceMetadataUrl: + "https://mcp.linear.app/.well-known/oauth-protected-resource/mcp", + resourceMetadata: { + resource: "https://mcp.linear.app/mcp", + authorization_servers: ["https://mcp.linear.app"], + }, + authorizationServerMetadata: { + issuer: "https://mcp.linear.app", + authorization_endpoint: "https://mcp.linear.app/authorize", + token_endpoint: "https://mcp.linear.app/token", + registration_endpoint: "https://mcp.linear.app/register", + }, + }; +} + function createJsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -111,6 +129,9 @@ describe("mcp-oauth", () => { async function seedPendingOAuth( registryServerId?: string, discoveryState: any = createAsanaDiscoveryState(), + useRegistryOAuthProxy?: boolean, + serverName: string = "asana", + serverUrl: string = "https://mcp.asana.com/v2/mcp", ) { mockSdkAuth.mockImplementationOnce(async (provider) => { await provider.saveDiscoveryState?.(discoveryState); @@ -120,9 +141,10 @@ describe("mcp-oauth", () => { const { initiateOAuth } = await import("../mcp-oauth"); const result = await initiateOAuth({ - serverName: "asana", - serverUrl: "https://mcp.asana.com/v2/mcp", + serverName, + serverUrl, registryServerId, + useRegistryOAuthProxy, }); expect(result).toEqual({ success: true }); @@ -225,9 +247,161 @@ describe("mcp-oauth", () => { expect.objectContaining({ method: "GET" }), ); }); + + it("preserves JSON bodies for dynamic client registration requests", async () => { + authFetch.mockResolvedValueOnce( + createJsonResponse({ + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + body: { + client_id: "linear-client-id", + redirect_uris: [`${window.location.origin}/oauth/callback`], + }, + }), + ); + mockSdkAuth.mockImplementationOnce(async (_provider, options) => { + const registrationBody = JSON.stringify({ + client_name: "MCPJam - Linear", + redirect_uris: [`${window.location.origin}/oauth/callback`], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }); + + const response = await options.fetchFn!( + "https://mcp.linear.app/register", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: registrationBody, + }, + ); + expect(response.ok).toBe(true); + return "REDIRECT"; + }); + + const { initiateOAuth } = await import("../mcp-oauth"); + const result = await initiateOAuth({ + serverName: "Linear", + serverUrl: "https://mcp.linear.app/mcp", + registryServerId: "registry-linear", + }); + + expect(result.success).toBe(true); + expect(authFetch).toHaveBeenCalledWith( + "/api/mcp/oauth/proxy", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://mcp.linear.app/register", + method: "POST", + headers: { "content-type": "application/json" }, + body: { + client_name: "MCPJam - Linear", + redirect_uris: [`${window.location.origin}/oauth/callback`], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + }, + }), + }), + ); + }); }); describe("persisted discovery state", () => { + it("returns safe defaults when stored OAuth config is missing or malformed", async () => { + const { readStoredOAuthConfig } = await import("../mcp-oauth"); + + expect(readStoredOAuthConfig("missing")).toEqual({ + registryServerId: undefined, + useRegistryOAuthProxy: false, + }); + + localStorage.setItem("mcp-oauth-config-bad", "{"); + expect(readStoredOAuthConfig("bad")).toEqual({ + registryServerId: undefined, + useRegistryOAuthProxy: false, + }); + }); + + it("reads stored registry routing config", async () => { + const { readStoredOAuthConfig } = await import("../mcp-oauth"); + + localStorage.setItem( + "mcp-oauth-config-linear", + JSON.stringify({ + scopes: ["read", "write"], + registryServerId: "registry-linear", + useRegistryOAuthProxy: true, + }), + ); + + expect(readStoredOAuthConfig("linear")).toEqual({ + scopes: ["read", "write"], + registryServerId: "registry-linear", + useRegistryOAuthProxy: true, + }); + }); + + it("detects OAuth token grant requests", async () => { + const { isOAuthTokenGrantRequest } = await import("../mcp-oauth"); + + expect( + isOAuthTokenGrantRequest("POST", { + grant_type: "authorization_code", + }), + ).toBe(true); + expect( + isOAuthTokenGrantRequest("POST", { + grant_type: "refresh_token", + }), + ).toBe(true); + expect( + isOAuthTokenGrantRequest("POST", { + client_name: "MCPJam - Linear", + }), + ).toBe(false); + expect( + isOAuthTokenGrantRequest("GET", { + grant_type: "authorization_code", + }), + ).toBe(false); + }); + + it("only uses registry OAuth proxy for preregistered registry token exchanges", async () => { + const { shouldUseRegistryOAuthProxy } = await import("../mcp-oauth"); + + expect( + shouldUseRegistryOAuthProxy({ + registryServerId: "registry-asana", + useRegistryOAuthProxy: true, + method: "POST", + body: { grant_type: "authorization_code" }, + }), + ).toBe(true); + + expect( + shouldUseRegistryOAuthProxy({ + registryServerId: "registry-linear", + useRegistryOAuthProxy: false, + method: "POST", + body: { grant_type: "authorization_code" }, + }), + ).toBe(false); + + expect( + shouldUseRegistryOAuthProxy({ + registryServerId: "registry-asana", + useRegistryOAuthProxy: true, + method: "POST", + body: { client_name: "MCPJam - Asana" }, + }), + ).toBe(false); + }); + it("round-trips discovery state for the matching server URL", async () => { const { MCPOAuthProvider } = await import("../mcp-oauth"); const discoveryState = createDiscoveryState(); @@ -375,7 +549,7 @@ describe("mcp-oauth", () => { }); const discoveryState = createAsanaDiscoveryState(); - await seedPendingOAuth("registry-asana", discoveryState); + await seedPendingOAuth("registry-asana", discoveryState, true); mockFetchToken.mockImplementationOnce(async (provider, authServerUrl, options) => { expect(authServerUrl).toBe("https://app.asana.com"); expect(options?.metadata?.token_endpoint).toBe( @@ -420,6 +594,17 @@ describe("mcp-oauth", () => { ); }); + it("persists preregistered registry routing for fresh Asana OAuth connects", async () => { + await seedPendingOAuth("registry-asana", createAsanaDiscoveryState(), true); + + expect(localStorage.getItem("mcp-oauth-config-asana")).toBe( + JSON.stringify({ + registryServerId: "registry-asana", + useRegistryOAuthProxy: true, + }), + ); + }); + it("routes Asana-style refresh token exchange through Convex for registry servers", async () => { authFetch.mockImplementationOnce(async (input: RequestInfo | URL) => { const url = @@ -459,7 +644,10 @@ describe("mcp-oauth", () => { localStorage.setItem("mcp-serverUrl-asana", "https://mcp.asana.com/v2/mcp"); localStorage.setItem( "mcp-oauth-config-asana", - JSON.stringify({ registryServerId: "registry-asana" }), + JSON.stringify({ + registryServerId: "registry-asana", + useRegistryOAuthProxy: true, + }), ); localStorage.setItem( "mcp-tokens-asana", @@ -513,7 +701,7 @@ describe("mcp-oauth", () => { throw new Error(`Unexpected direct fetch to ${url}`); }); - await seedPendingOAuth("registry-asana"); + await seedPendingOAuth("registry-asana", undefined, true); mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { const response = await options!.fetchFn!( "https://app.asana.com/-/oauth_token", @@ -601,5 +789,209 @@ describe("mcp-oauth", () => { }), ); }); + + it("uses the generic Inspector OAuth proxy for Asana when stored config is missing the preregistered flag", async () => { + const browserFetch = vi.fn(); + vi.stubGlobal("fetch", browserFetch); + await seedPendingOAuth( + "registry-asana", + createAsanaDiscoveryState(), + false, + "asana", + "https://mcp.asana.com/v2/mcp", + ); + authFetch.mockResolvedValueOnce( + createJsonResponse({ + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + body: { + access_token: "asana-access-token", + refresh_token: "asana-refresh-token", + token_type: "Bearer", + }, + }), + ); + mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { + const response = await options!.fetchFn!( + "https://app.asana.com/-/oauth_token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + return await response.json(); + }); + + const { handleOAuthCallback } = await import("../mcp-oauth"); + const callbackResult = await handleOAuthCallback("oauth-code"); + + expect(callbackResult.success).toBe(true); + expect(browserFetch).not.toHaveBeenCalled(); + expect(authFetch).toHaveBeenCalledWith( + "/api/mcp/oauth/proxy", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://app.asana.com/-/oauth_token", + method: "POST", + headers: {}, + body: { + grant_type: "authorization_code", + code: "oauth-code", + code_verifier: "test-verifier", + redirect_uri: `${window.location.origin}/oauth/callback`, + }, + }), + }), + ); + expect(authFetch).not.toHaveBeenCalledWith( + expect.stringMatching(/\.convex\.site\/registry\/oauth\/token$/), + expect.anything(), + ); + }); + + it("uses the generic Inspector OAuth proxy for Linear-style registry callback token exchange", async () => { + const browserFetch = vi.fn(); + vi.stubGlobal("fetch", browserFetch); + await seedPendingOAuth( + "registry-linear", + createLinearDiscoveryState(), + false, + "linear", + "https://mcp.linear.app/mcp", + ); + authFetch.mockResolvedValueOnce( + createJsonResponse({ + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + body: { + access_token: "linear-access-token", + refresh_token: "linear-refresh-token", + token_type: "Bearer", + }, + }), + ); + mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { + const response = await options!.fetchFn!( + "https://mcp.linear.app/token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + return await response.json(); + }); + + const { handleOAuthCallback } = await import("../mcp-oauth"); + const callbackResult = await handleOAuthCallback("oauth-code"); + + expect(callbackResult.success).toBe(true); + expect(browserFetch).not.toHaveBeenCalled(); + expect(authFetch).toHaveBeenCalledWith( + "/api/mcp/oauth/proxy", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://mcp.linear.app/token", + method: "POST", + headers: {}, + body: { + grant_type: "authorization_code", + code: "oauth-code", + code_verifier: "test-verifier", + redirect_uri: `${window.location.origin}/oauth/callback`, + }, + }), + }), + ); + expect(authFetch).not.toHaveBeenCalledWith( + expect.stringMatching(/\.convex\.site\/registry\/oauth\/token$/), + expect.anything(), + ); + }); + + it("uses the generic Inspector OAuth proxy for Linear-style registry refresh token exchange", async () => { + authFetch.mockResolvedValueOnce( + createJsonResponse({ + status: 200, + statusText: "OK", + headers: { "Content-Type": "application/json" }, + body: { + access_token: "new-linear-access-token", + refresh_token: "new-linear-refresh-token", + token_type: "Bearer", + }, + }), + ); + + mockSdkAuth.mockImplementationOnce(async (_provider, options) => { + const response = await options.fetchFn!( + "https://mcp.linear.app/token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "stored-refresh-token", + }), + }, + ); + const tokens = await response.json(); + await _provider.saveTokens(tokens); + return "AUTHORIZED"; + }); + + localStorage.setItem("mcp-serverUrl-linear", "https://mcp.linear.app/mcp"); + localStorage.setItem( + "mcp-oauth-config-linear", + JSON.stringify({ registryServerId: "registry-linear" }), + ); + localStorage.setItem( + "mcp-tokens-linear", + JSON.stringify({ + access_token: "old-linear-access-token", + refresh_token: "stored-refresh-token", + token_type: "Bearer", + }), + ); + + const { refreshOAuthTokens } = await import("../mcp-oauth"); + const refreshResult = await refreshOAuthTokens("linear"); + + expect(refreshResult.success).toBe(true); + expect(authFetch).toHaveBeenCalledWith( + "/api/mcp/oauth/proxy", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://mcp.linear.app/token", + method: "POST", + headers: {}, + body: { + grant_type: "refresh_token", + refresh_token: "stored-refresh-token", + }, + }), + }), + ); + expect(authFetch).not.toHaveBeenCalledWith( + expect.stringMatching(/\.convex\.site\/registry\/oauth\/refresh$/), + expect.anything(), + ); + }); }); }); diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index 67228c074..41e2452cd 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -18,6 +18,7 @@ import { generateRandomString } from "./state-machines/shared/helpers"; import { authFetch } from "@/lib/session-token"; import { HOSTED_MODE } from "@/lib/config"; import { captureServerDetailModalOAuthResume } from "@/lib/server-detail-modal-resume"; +import { getRedirectUri } from "./constants"; // Store original fetch for restoration const originalFetch = window.fetch; @@ -40,6 +41,18 @@ interface StoredOAuthDiscoveryState { discoveryState: OAuthDiscoveryState; } +export interface StoredOAuthConfig { + scopes?: string[]; + customHeaders?: Record; + registryServerId?: string; + useRegistryOAuthProxy?: boolean; +} + +interface OAuthRoutingConfig { + registryServerId?: string; + useRegistryOAuthProxy?: boolean; +} + function getDiscoveryStorageKey(serverName: string): string { return `mcp-discovery-${serverName}`; } @@ -50,24 +63,113 @@ function clearStoredDiscoveryState(serverName: string): void { type OAuthRequestFields = Record; -function getStoredRegistryServerId( +export function readStoredOAuthConfig( serverName: string | null, -): string | undefined { - if (!serverName) return undefined; +): StoredOAuthConfig { + if (!serverName) { + return { + registryServerId: undefined, + useRegistryOAuthProxy: false, + }; + } + try { const raw = localStorage.getItem(`mcp-oauth-config-${serverName}`); - if (!raw) return undefined; - return JSON.parse(raw).registryServerId; + if (!raw) { + return { + registryServerId: undefined, + useRegistryOAuthProxy: false, + }; + } + + const parsed = JSON.parse(raw); + const config: StoredOAuthConfig = { + registryServerId: + typeof parsed?.registryServerId === "string" + ? parsed.registryServerId + : undefined, + useRegistryOAuthProxy: parsed?.useRegistryOAuthProxy === true, + }; + + if ( + Array.isArray(parsed?.scopes) && + parsed.scopes.every((scope: unknown) => typeof scope === "string") + ) { + config.scopes = parsed.scopes; + } + + if ( + parsed?.customHeaders && + typeof parsed.customHeaders === "object" && + !Array.isArray(parsed.customHeaders) + ) { + config.customHeaders = Object.fromEntries( + Object.entries(parsed.customHeaders).filter( + ([, value]) => typeof value === "string", + ) as Array<[string, string]>, + ); + } + + return config; } catch { - return undefined; + return { + registryServerId: undefined, + useRegistryOAuthProxy: false, + }; } } +export function buildStoredOAuthConfig( + options: Pick< + MCPOAuthOptions, + "scopes" | "registryServerId" | "useRegistryOAuthProxy" + >, +): StoredOAuthConfig { + const config: StoredOAuthConfig = { + registryServerId: options.registryServerId, + useRegistryOAuthProxy: options.useRegistryOAuthProxy === true, + }; + + if (options.scopes && options.scopes.length > 0) { + config.scopes = options.scopes; + } + + return config; +} + function parseOAuthRequestFields(body: unknown): OAuthRequestFields | undefined { if (!body) return undefined; if (typeof body === "string") { - const params = new URLSearchParams(body); + const trimmed = body.trim(); + if (!trimmed) { + return undefined; + } + + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + const entries = Object.entries(parsed).flatMap(([key, value]) => { + if (typeof value === "string") { + return [[key, value] as const]; + } + if (typeof value === "number" || typeof value === "boolean") { + return [[key, String(value)] as const]; + } + return []; + }); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; + } + } catch { + // Fall through to URLSearchParams parsing. + } + } + + const params = new URLSearchParams(trimmed); const entries = Object.fromEntries(params.entries()); return Object.keys(entries).length > 0 ? entries : undefined; } @@ -93,12 +195,11 @@ function getOAuthGrantType(body: unknown): string | undefined { return parseOAuthRequestFields(body)?.grant_type; } -function isRegistryTokenGrantRequest( - registryServerId: string | undefined, +export function isOAuthTokenGrantRequest( method: string, body: unknown, ): body is OAuthRequestFields { - if (!registryServerId || method !== "POST") { + if (method !== "POST") { return false; } @@ -106,6 +207,22 @@ function isRegistryTokenGrantRequest( return grantType === "authorization_code" || grantType === "refresh_token"; } +export function shouldUseRegistryOAuthProxy({ + registryServerId, + useRegistryOAuthProxy, + method, + body, +}: OAuthRoutingConfig & { + method: string; + body: unknown; +}): body is OAuthRequestFields { + if (!registryServerId || !useRegistryOAuthProxy) { + return false; + } + + return isOAuthTokenGrantRequest(method, body); +} + function toConvexOAuthPayload( registryServerId: string, fields: OAuthRequestFields, @@ -186,7 +303,9 @@ async function loadCallbackDiscoveryState( * When a registryServerId is provided, token exchange/refresh is routed through * the Convex HTTP registry OAuth endpoints which inject server-side secrets. */ -function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { +function createOAuthFetchInterceptor( + routingConfig: OAuthRoutingConfig = {}, +): typeof fetch { return async function interceptedFetch( input: RequestInfo | URL, init?: RequestInit, @@ -200,11 +319,11 @@ function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { ? input.toString() : input.url; const oauthGrantType = getOAuthGrantType(serializedBody); - const isRegistryTokenRequest = isRegistryTokenGrantRequest( - registryServerId, + const isRegistryTokenRequest = shouldUseRegistryOAuthProxy({ + ...routingConfig, method, - serializedBody, - ); + body: serializedBody, + }); // Check if this is an OAuth-related request that needs CORS bypass const isOAuthRequest = @@ -222,7 +341,9 @@ function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { "[OAuthDebug] interceptedFetch:", url, "registryServerId:", - registryServerId, + routingConfig.registryServerId, + "useRegistryOAuthProxy:", + routingConfig.useRegistryOAuthProxy, "method:", method, "grantType:", @@ -247,7 +368,10 @@ function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify( - toConvexOAuthPayload(registryServerId, serializedBody), + toConvexOAuthPayload( + routingConfig.registryServerId!, + serializedBody, + ), ), }); console.log( @@ -316,7 +440,18 @@ function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch { */ async function serializeBody(body: BodyInit): Promise { if (typeof body === "string") { - return parseOAuthRequestFields(body) ?? body; + const trimmed = body.trim(); + if ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + try { + return JSON.parse(trimmed); + } catch { + // Fall back to form-style parsing below. + } + } + return parseOAuthRequestFields(trimmed) ?? body; } if (body instanceof URLSearchParams || body instanceof FormData) { return Object.fromEntries(body.entries()); @@ -331,8 +466,10 @@ export interface MCPOAuthOptions { scopes?: string[]; clientId?: string; clientSecret?: string; - /** When set, uses Convex /registry/oauth/* routes instead of standard proxy */ + /** Registry record identifier for bookkeeping and optional Convex token exchange */ registryServerId?: string; + /** True only for registry servers with backend-managed preregistered OAuth credentials */ + useRegistryOAuthProxy?: boolean; } export interface OAuthResult { @@ -359,7 +496,7 @@ export class MCPOAuthProvider implements OAuthClientProvider { ) { this.serverName = serverName; this.serverUrl = serverUrl; - this.redirectUri = `${window.location.origin}/oauth/callback`; + this.redirectUri = getRedirectUri(); this.customClientId = customClientId; this.customClientSecret = customClientSecret; } @@ -529,7 +666,10 @@ export async function initiateOAuth( options: MCPOAuthOptions, ): Promise { // Build fetch interceptor — routes token requests through Convex for registry servers - const fetchFn = createOAuthFetchInterceptor(options.registryServerId); + const fetchFn = createOAuthFetchInterceptor({ + registryServerId: options.registryServerId, + useRegistryOAuthProxy: options.useRegistryOAuthProxy, + }); try { const provider = new MCPOAuthProvider( @@ -548,13 +688,7 @@ export async function initiateOAuth( console.log("[OAuthDebug] SET mcp-oauth-pending =", options.serverName, "registryServerId:", options.registryServerId, "clientId:", options.clientId, "(initiateOAuth)"); // ##TODOClean // Store OAuth configuration (scopes, registryServerId) for recovery if connection fails - const oauthConfig: any = {}; - if (options.scopes && options.scopes.length > 0) { - oauthConfig.scopes = options.scopes; - } - if (options.registryServerId) { - oauthConfig.registryServerId = options.registryServerId; - } + const oauthConfig = buildStoredOAuthConfig(options); localStorage.setItem( `mcp-oauth-config-${options.serverName}`, JSON.stringify(oauthConfig), @@ -653,11 +787,11 @@ export async function handleOAuthCallback( console.log("[OAuthDebug] handleOAuthCallback: mcp-oauth-pending =", serverName); // ##TODOClean // Read registryServerId from stored OAuth config if present - const registryServerId = getStoredRegistryServerId(serverName); - console.log("[OAuthDebug] handleOAuthCallback: registryServerId =", registryServerId, "oauthConfig =", localStorage.getItem(`mcp-oauth-config-${serverName}`)); // ##TODOClean + const oauthConfig = readStoredOAuthConfig(serverName); + console.log("[OAuthDebug] handleOAuthCallback: registryServerId =", oauthConfig.registryServerId, "oauthConfig =", localStorage.getItem(`mcp-oauth-config-${serverName}`)); // ##TODOClean // Build fetch interceptor — routes token requests through Convex for registry servers - const fetchFn = createOAuthFetchInterceptor(registryServerId); + const fetchFn = createOAuthFetchInterceptor(oauthConfig); try { if (!serverName) { @@ -836,8 +970,8 @@ export async function refreshOAuthTokens( serverName: string, ): Promise { // Build fetch interceptor — routes token requests through Convex for registry servers - const registryServerId = getStoredRegistryServerId(serverName); - const fetchFn = createOAuthFetchInterceptor(registryServerId); + const oauthConfig = readStoredOAuthConfig(serverName); + const fetchFn = createOAuthFetchInterceptor(oauthConfig); try { // Get stored client credentials if any diff --git a/mcpjam-inspector/client/src/state/oauth-orchestrator.ts b/mcpjam-inspector/client/src/state/oauth-orchestrator.ts index f97b9d618..8c1ef8177 100644 --- a/mcpjam-inspector/client/src/state/oauth-orchestrator.ts +++ b/mcpjam-inspector/client/src/state/oauth-orchestrator.ts @@ -3,6 +3,7 @@ import { getStoredTokens, hasOAuthConfig, initiateOAuth, + readStoredOAuthConfig, refreshOAuthTokens, MCPOAuthOptions, } from "@/lib/oauth/mcp-oauth"; @@ -53,15 +54,12 @@ export async function ensureAuthorizedForReconnect( // This may redirect away; the hook should reflect oauth-flow state const storedServerUrl = localStorage.getItem(`mcp-serverUrl-${server.name}`); const storedClientInfo = localStorage.getItem(`mcp-client-${server.name}`); - const storedOAuthConfig = localStorage.getItem( - `mcp-oauth-config-${server.name}`, - ); const storedTokens = getStoredTokens(server.name); const url = (server.config as any)?.url?.toString?.() || storedServerUrl; if (url) { // Get stored OAuth configuration - const oauthConfig = storedOAuthConfig ? JSON.parse(storedOAuthConfig) : {}; + const oauthConfig = readStoredOAuthConfig(server.name); const clientInfo = storedClientInfo ? JSON.parse(storedClientInfo) : {}; const opts: MCPOAuthOptions = { @@ -75,6 +73,7 @@ export async function ensureAuthorizedForReconnect( server.oauthTokens?.client_secret || clientInfo?.client_secret, scopes: oauthConfig.scopes, registryServerId: oauthConfig.registryServerId, + useRegistryOAuthProxy: oauthConfig.useRegistryOAuthProxy, } as MCPOAuthOptions; const init = await initiateOAuth(opts); if (init.success && init.serverConfig) { diff --git a/mcpjam-inspector/client/vite.config.ts b/mcpjam-inspector/client/vite.config.ts index 25435a49b..6870b4986 100644 --- a/mcpjam-inspector/client/vite.config.ts +++ b/mcpjam-inspector/client/vite.config.ts @@ -8,6 +8,8 @@ import { readFileSync } from "fs"; const clientDir = fileURLToPath(new URL(".", import.meta.url)); const rootDir = path.resolve(clientDir, ".."); +// The linked local SDK package can advertise ./browser before dist/browser.* exists. +const sdkBrowserEntry = path.resolve(rootDir, "../sdk/src/browser.ts"); // Read version from package.json const packageJson = JSON.parse( @@ -41,6 +43,7 @@ export default defineConfig(({ mode }) => { "@repo/assets": path.resolve(clientDir, "src/assets"), "@/shared": path.resolve(clientDir, "../shared"), "@": path.resolve(clientDir, "./src"), + "@mcpjam/sdk/browser": sdkBrowserEntry, // Force React resolution to prevent conflicts with @mcp-ui/client react: path.resolve(clientDir, "../node_modules/react"), "react-dom": path.resolve(clientDir, "../node_modules/react-dom"), diff --git a/mcpjam-inspector/shared/types.ts b/mcpjam-inspector/shared/types.ts index cffea9bd0..9ab9b372e 100644 --- a/mcpjam-inspector/shared/types.ts +++ b/mcpjam-inspector/shared/types.ts @@ -599,8 +599,10 @@ export interface ServerFormData { clientSecret?: string; /** Registry credential key for resolving OAuth client ID from env (e.g. "github") */ oauthCredentialKey?: string; + /** True for registry servers that use backend-managed preregistered OAuth credentials */ + useRegistryOAuthProxy?: boolean; requestTimeout?: number; - /** Convex _id of the registry server (for OAuth routing via /registry/oauth/token) */ + /** Convex _id of the registry server for workspace/registry bookkeeping */ registryServerId?: string; } From ad8d43fe3a29e26d43aaf621b095b9302a3de100 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Tue, 24 Mar 2026 16:58:36 -0700 Subject: [PATCH 11/21] loading flows --- .../client/src/components/RegistryTab.tsx | 67 ++++-- .../client/src/components/ServersTab.tsx | 189 ++++++++++++---- .../components/__tests__/RegistryTab.test.tsx | 44 ++-- .../components/__tests__/ServersTab.test.tsx | 210 +++++++++++++++++- .../connection/ServerConnectionCard.tsx | 45 +++- .../__tests__/ServerConnectionCard.test.tsx | 22 +- .../__tests__/server-card-utils.test.ts | 12 +- .../connection/server-card-utils.ts | 4 +- .../__tests__/quick-connect-pending.test.ts | 64 ++++++ .../client/src/lib/quick-connect-pending.ts | 67 ++++++ 10 files changed, 635 insertions(+), 89 deletions(-) create mode 100644 mcpjam-inspector/client/src/lib/__tests__/quick-connect-pending.test.ts create mode 100644 mcpjam-inspector/client/src/lib/quick-connect-pending.ts diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index 0fdcc77e5..5feb1661d 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -33,6 +33,12 @@ import { } from "@/hooks/useRegistryServers"; import type { ServerFormData } from "@/shared/types.js"; import type { ServerWithName } from "@/hooks/use-app-state"; +import { + clearPendingQuickConnect, + readPendingQuickConnect, + writePendingQuickConnect, + type PendingQuickConnectState, +} from "@/lib/quick-connect-pending"; interface RegistryTabProps { workspaceId: string | null; @@ -54,34 +60,38 @@ export function RegistryTab({ // isAuthenticated is passed through to the hook for Convex mutation gating, // but the registry is always browsable without auth. const [connectingIds, setConnectingIds] = useState>(new Set()); + const [pendingQuickConnect, setPendingQuickConnect] = + useState(() => readPendingQuickConnect()); - const { registryServers, categories, isLoading, connect, disconnect } = - useRegistryServers({ + const { registryServers, isLoading, connect, disconnect } = useRegistryServers( + { workspaceId, isAuthenticated, liveServers: servers, onConnect, onDisconnect, - }); + }, + ); // Auto-redirect to App Builder when a pending server becomes connected. // We persist in localStorage to survive OAuth redirects (page remounts). useEffect(() => { if (!onNavigate) return; - const pending = localStorage.getItem("registry-pending-redirect"); - if (!pending) return; + const pending = pendingQuickConnect; + if (!pending || pending.sourceTab !== "registry") return; const liveServer = - servers?.[pending] ?? + servers?.[pending.serverName] ?? Object.entries(servers ?? {}).find( ([name, server]) => server.connectionStatus === "connected" && - name.startsWith(`${pending} (`), + name.startsWith(`${pending.displayName} (`), )?.[1]; if (liveServer?.connectionStatus === "connected") { - localStorage.removeItem("registry-pending-redirect"); + clearPendingQuickConnect(); + setPendingQuickConnect(null); onNavigate("app-builder"); } - }, [servers, onNavigate]); + }, [pendingQuickConnect, servers, onNavigate]); const consolidatedServers = useMemo( () => consolidateServers(registryServers), @@ -91,14 +101,20 @@ export function RegistryTab({ const handleConnect = async (server: EnrichedRegistryServer) => { setConnectingIds((prev) => new Set(prev).add(server._id)); const serverName = getRegistryServerName(server); - localStorage.setItem("registry-pending-redirect", serverName); + const nextPendingQuickConnect: PendingQuickConnectState = { + serverName, + registryServerId: server._id, + displayName: server.displayName, + sourceTab: "registry", + createdAt: Date.now(), + }; + writePendingQuickConnect(nextPendingQuickConnect); + setPendingQuickConnect(nextPendingQuickConnect); try { await connect(server); } catch (error) { - const pending = localStorage.getItem("registry-pending-redirect"); - if (pending === serverName || pending === server.displayName) { - localStorage.removeItem("registry-pending-redirect"); - } + clearPendingQuickConnect(); + setPendingQuickConnect(null); throw error; } finally { setConnectingIds((prev) => { @@ -111,9 +127,13 @@ export function RegistryTab({ const handleDisconnect = async (server: EnrichedRegistryServer) => { const serverName = getRegistryServerName(server); - const pending = localStorage.getItem("registry-pending-redirect"); - if (pending === serverName || pending === server.displayName) { - localStorage.removeItem("registry-pending-redirect"); + if ( + pendingQuickConnect && + (pendingQuickConnect.serverName === serverName || + pendingQuickConnect.displayName === server.displayName) + ) { + clearPendingQuickConnect(); + setPendingQuickConnect(null); } await disconnect(server); }; @@ -150,6 +170,7 @@ export function RegistryTab({ key={consolidated.variants[0]._id} consolidated={consolidated} connectingIds={connectingIds} + pendingQuickConnect={pendingQuickConnect} onConnect={handleConnect} onDisconnect={handleDisconnect} /> @@ -163,18 +184,28 @@ export function RegistryTab({ function RegistryServerCard({ consolidated, connectingIds, + pendingQuickConnect, onConnect, onDisconnect, }: { consolidated: ConsolidatedRegistryServer; connectingIds: Set; + pendingQuickConnect: PendingQuickConnectState | null; onConnect: (server: EnrichedRegistryServer) => void; onDisconnect: (server: EnrichedRegistryServer) => void; }) { const { variants, hasDualType } = consolidated; const first = variants[0]; - const isConnecting = variants.some((v) => connectingIds.has(v._id)); + const isConnecting = + variants.some((v) => connectingIds.has(v._id)) || + (pendingQuickConnect?.sourceTab === "registry" && + variants.some( + (variant) => + variant._id === pendingQuickConnect.registryServerId || + getRegistryServerName(variant) === pendingQuickConnect.serverName || + variant.displayName === pendingQuickConnect.displayName, + )); const effectiveStatus: RegistryConnectionStatus = isConnecting ? "connecting" : first.connectionStatus; diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 9cd495e49..d7d9f4ca2 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; -import { Plus, FileText, Package, ArrowRight } from "lucide-react"; +import { Plus, FileText, Package, ArrowRight, Loader2 } from "lucide-react"; import { ServerWithName, type ServerUpdateResult } from "@/hooks/use-app-state"; import { ServerConnectionCard } from "./connection/ServerConnectionCard"; import { AddServerModal } from "./connection/AddServerModal"; @@ -29,6 +29,12 @@ import { useJsonRpcPanelVisibility } from "@/hooks/use-json-rpc-panel"; import { Skeleton } from "./ui/skeleton"; import { useConvexAuth } from "convex/react"; import { Workspace } from "@/state/app-types"; +import { + clearPendingQuickConnect, + readPendingQuickConnect, + writePendingQuickConnect, + type PendingQuickConnectState, +} from "@/lib/quick-connect-pending"; import { useWorkspaceServers as useRemoteWorkspaceServers } from "@/hooks/useWorkspaces"; import { getEffectiveServerClientCapabilities, @@ -169,11 +175,12 @@ export function ServersTab({ activeWorkspaceId, isLoadingWorkspaces, onWorkspaceShared, - onLeaveWorkspace, onNavigateToRegistry, }: ServersTabProps) { const posthog = usePostHog(); const { isAuthenticated } = useConvexAuth(); + const [pendingQuickConnect, setPendingQuickConnect] = + useState(() => readPendingQuickConnect()); // Fetch featured registry servers for the quick-connect section const registryServers = useQuery( @@ -335,7 +342,44 @@ export function ServersTab({ }); }, []); + useEffect(() => { + if (pendingQuickConnect?.sourceTab !== "servers") { + return; + } + + const pendingServer = workspaceServers[pendingQuickConnect.serverName]; + if (!pendingServer) { + return; + } + + if ( + pendingServer.connectionStatus === "connected" || + pendingServer.connectionStatus === "failed" || + pendingServer.connectionStatus === "disconnected" + ) { + clearPendingQuickConnect(); + setPendingQuickConnect(null); + } + }, [pendingQuickConnect, workspaceServers]); + const connectedCount = Object.keys(workspaceServers).length; + const hasConnectedServers = Object.values(workspaceServers).some( + (server) => server.connectionStatus === "connected", + ); + const hasAnyServers = connectedCount > 0; + const pendingQuickConnectServer = + pendingQuickConnect?.sourceTab === "servers" + ? workspaceServers[pendingQuickConnect.serverName] + : null; + const isPendingQuickConnectVisible = + pendingQuickConnect?.sourceTab === "servers" && + (!pendingQuickConnectServer || + pendingQuickConnectServer.connectionStatus === "oauth-flow" || + pendingQuickConnectServer.connectionStatus === "connecting"); + const pendingQuickConnectPhaseLabel = + pendingQuickConnectServer?.connectionStatus === "connecting" + ? "Finishing setup..." + : "Authorizing..."; const activeWorkspace = workspaces[activeWorkspaceId]; const sharedWorkspaceId = activeWorkspace?.sharedWorkspaceId; const { serversRecord: sharedWorkspaceServersRecord } = @@ -428,6 +472,38 @@ export function ServersTab({ }); }; + const handleQuickConnect = (server: RegistryServer) => { + const nextPendingQuickConnect: PendingQuickConnectState = { + serverName: server.displayName, + registryServerId: server._id, + displayName: server.displayName, + sourceTab: "servers", + createdAt: Date.now(), + }; + writePendingQuickConnect(nextPendingQuickConnect); + setPendingQuickConnect(nextPendingQuickConnect); + onConnect({ + name: server.displayName, + type: server.transport.transportType, + url: server.transport.url, + useOAuth: server.transport.useOAuth, + oauthScopes: server.transport.oauthScopes, + oauthCredentialKey: server.transport.oauthCredentialKey, + registryServerId: server._id, + }); + }; + + const clearPendingQuickConnectIfMatches = useCallback( + (serverName: string) => { + if (pendingQuickConnect?.serverName !== serverName) { + return; + } + clearPendingQuickConnect(); + setPendingQuickConnect(null); + }, + [pendingQuickConnect], + ); + const handleAddServerClick = () => { posthog.capture("add_server_button_clicked", { location: "servers_tab", @@ -611,47 +687,84 @@ export function ServersTab({ )}
-
- {featuredRegistryServers.map((server) => ( - - ))} +
+
+ )} +
+ {featuredRegistryServers.map((server) => { + const isPendingServer = + pendingQuickConnect?.sourceTab === "servers" && + (pendingQuickConnect.registryServerId === server._id || + pendingQuickConnect.serverName === server.displayName); + + return ( + + ); + })}
+ {isPendingQuickConnectVisible && pendingQuickConnectServer && ( + { + clearPendingQuickConnectIfMatches(serverName); + onDisconnect(serverName); + }} + onReconnect={onReconnect} + onRemove={(serverName) => { + clearPendingQuickConnectIfMatches(serverName); + onRemove(serverName); + }} + hostedServerId={ + sharedWorkspaceServersRecord[pendingQuickConnectServer.name]?._id + } + onOpenDetailModal={handleOpenDetailModal} + /> + )}
)} @@ -688,7 +801,7 @@ export function ServersTab({
{isLoadingWorkspaces ? renderLoadingContent() - : connectedCount > 0 + : hasConnectedServers || (hasAnyServers && !isPendingQuickConnectVisible) ? renderConnectedContent() : renderEmptyContent()} diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index b569321ab..e29665430 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { RegistryTab } from "../RegistryTab"; import type { EnrichedRegistryServer } from "@/hooks/useRegistryServers"; +import { readPendingQuickConnect } from "@/lib/quick-connect-pending"; // Mock the useRegistryServers hook const mockConnect = vi.fn(); @@ -53,18 +54,23 @@ function createMockServer( ): EnrichedRegistryServer { return { _id: "server_1", - slug: "test-server", + name: "com.test.server", displayName: "Test Server", description: "A test MCP server for unit tests.", publisher: "TestCo", category: "Productivity", - transport: { type: "http", url: "https://mcp.test.com/sse" }, - approved: true, + scope: "global", + transport: { + transportType: "http", + url: "https://mcp.test.com/sse", + }, + status: "approved", + createdBy: "test-user", createdAt: Date.now(), updatedAt: Date.now(), connectionStatus: "not_connected", ...overrides, - }; + } as EnrichedRegistryServer; } describe("RegistryTab", () => { @@ -161,7 +167,7 @@ describe("RegistryTab", () => { registryServers: [ createMockServer({ transport: { - type: "http", + transportType: "http", url: "https://mcp.test.com/sse", useOAuth: true, }, @@ -201,7 +207,7 @@ describe("RegistryTab", () => { publisher: "MCPJam", category: "Project Management", transport: { - type: "http", + transportType: "http", url: "https://mcp.linear.app/sse", useOAuth: true, }, @@ -392,10 +398,16 @@ describe("RegistryTab", () => { , ); - // Click connect — stores pending redirect in localStorage + // Click connect — stores structured pending state in localStorage fireEvent.click(screen.getByText("Connect")); await waitFor(() => expect(mockConnect).toHaveBeenCalled()); - expect(localStorage.getItem("registry-pending-redirect")).toBe("Asana"); + expect(readPendingQuickConnect()).toEqual({ + serverName: "Asana", + registryServerId: "server_1", + displayName: "Asana", + sourceTab: "registry", + createdAt: expect.any(Number), + }); // Simulate server becoming connected via props update rerender( @@ -418,7 +430,7 @@ describe("RegistryTab", () => { expect(onNavigate).toHaveBeenCalledWith("app-builder"); }); // localStorage should be cleaned up - expect(localStorage.getItem("registry-pending-redirect")).toBeNull(); + expect(readPendingQuickConnect()).toBeNull(); }); it("survives page remount (OAuth redirect) and still auto-redirects", async () => { @@ -456,7 +468,7 @@ describe("RegistryTab", () => { await waitFor(() => { expect(onNavigate).toHaveBeenCalledWith("app-builder"); }); - expect(localStorage.getItem("registry-pending-redirect")).toBeNull(); + expect(readPendingQuickConnect()).toBeNull(); }); it("redirects when a legacy pending display name matches a suffixed connected variant", async () => { @@ -495,7 +507,7 @@ describe("RegistryTab", () => { await waitFor(() => { expect(onNavigate).toHaveBeenCalledWith("app-builder"); }); - expect(localStorage.getItem("registry-pending-redirect")).toBeNull(); + expect(readPendingQuickConnect()).toBeNull(); }); }); @@ -637,9 +649,13 @@ describe("RegistryTab", () => { await waitFor(() => { expect(mockConnect).toHaveBeenCalled(); }); - expect(localStorage.getItem("registry-pending-redirect")).toBe( - "Asana (App)", - ); + expect(readPendingQuickConnect()).toEqual({ + serverName: "Asana (App)", + registryServerId: "asana-app", + displayName: "Asana", + sourceTab: "registry", + createdAt: expect.any(Number), + }); }); }); }); diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index 467716011..0039404aa 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -10,6 +10,21 @@ import { captureServerDetailModalOAuthResume, writeOpenServerDetailModalState, } from "@/lib/server-detail-modal-resume"; +import { writePendingQuickConnect } from "@/lib/quick-connect-pending"; + +let mockIsAuthenticated = false; +let mockRegistryServers: Array<{ + _id: string; + displayName: string; + publisher?: string; + transport: { + transportType: "http" | "stdio"; + url?: string; + useOAuth?: boolean; + oauthScopes?: string[]; + oauthCredentialKey?: string; + }; +}> | undefined; vi.mock("posthog-js/react", () => ({ usePostHog: () => ({ @@ -19,9 +34,9 @@ vi.mock("posthog-js/react", () => ({ vi.mock("convex/react", () => ({ useConvexAuth: () => ({ - isAuthenticated: false, + isAuthenticated: mockIsAuthenticated, }), - useQuery: () => undefined, + useQuery: () => mockRegistryServers, })); vi.mock("@workos-inc/authkit-react", () => ({ @@ -58,6 +73,9 @@ vi.mock("../connection/ServerConnectionCard", () => ({ Open {server.name} {needsReconnect ? Needs reconnect : null} +
+ {server.name}:{server.connectionStatus} +
), })); @@ -255,6 +273,8 @@ describe("ServersTab shared detail modal", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + mockIsAuthenticated = false; + mockRegistryServers = undefined; }); it("opens the shared modal from a server card on configuration", () => { @@ -497,4 +517,190 @@ describe("ServersTab shared detail modal", () => { expect(screen.queryByText("Needs reconnect")).not.toBeInTheDocument(); }); + + it("keeps quick connect visible after clicking a quick connect server", () => { + mockIsAuthenticated = true; + mockRegistryServers = [ + { + _id: "linear-1", + displayName: "Linear", + publisher: "MCPJam", + transport: { + transportType: "http", + url: "https://mcp.linear.app/mcp", + useOAuth: true, + oauthScopes: ["read", "write"], + }, + }, + ]; + + render(); + + fireEvent.click(screen.getByLabelText("Connect Linear")); + + expect(defaultProps.onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Linear", + type: "http", + url: "https://mcp.linear.app/mcp", + useOAuth: true, + registryServerId: "linear-1", + }), + ); + expect(screen.getByText("Quick Connect")).toBeInTheDocument(); + expect(screen.getByText("Connecting Linear...")).toBeInTheDocument(); + expect(screen.getByText("Authorizing...")).toBeInTheDocument(); + expect(screen.getByLabelText("Connect Linear")).toBeDisabled(); + }); + + it("keeps quick connect visible during oauth-flow after return", () => { + mockIsAuthenticated = true; + mockRegistryServers = [ + { + _id: "linear-1", + displayName: "Linear", + publisher: "MCPJam", + transport: { + transportType: "http", + url: "https://mcp.linear.app/mcp", + useOAuth: true, + }, + }, + ]; + writePendingQuickConnect({ + serverName: "Linear", + registryServerId: "linear-1", + displayName: "Linear", + sourceTab: "servers", + createdAt: 123, + }); + + render( + , + ); + + expect(screen.getByText("Quick Connect")).toBeInTheDocument(); + expect(screen.getByText("Connecting Linear...")).toBeInTheDocument(); + expect(screen.getByText("Authorizing...")).toBeInTheDocument(); + expect(screen.getByTestId("server-card-Linear")).toHaveTextContent( + "Linear:oauth-flow", + ); + }); + + it("shows finishing setup copy while the pending quick connect is connecting", () => { + mockIsAuthenticated = true; + mockRegistryServers = [ + { + _id: "linear-1", + displayName: "Linear", + publisher: "MCPJam", + transport: { + transportType: "http", + url: "https://mcp.linear.app/mcp", + useOAuth: true, + }, + }, + ]; + writePendingQuickConnect({ + serverName: "Linear", + registryServerId: "linear-1", + displayName: "Linear", + sourceTab: "servers", + createdAt: 123, + }); + + render( + , + ); + + expect(screen.getByText("Finishing setup...")).toBeInTheDocument(); + expect(screen.getByTestId("server-card-Linear")).toHaveTextContent( + "Linear:connecting", + ); + }); + + it("clears pending quick connect UI once the server is fully connected", () => { + mockIsAuthenticated = true; + mockRegistryServers = [ + { + _id: "linear-1", + displayName: "Linear", + publisher: "MCPJam", + transport: { + transportType: "http", + url: "https://mcp.linear.app/mcp", + useOAuth: true, + }, + }, + ]; + writePendingQuickConnect({ + serverName: "Linear", + registryServerId: "linear-1", + displayName: "Linear", + sourceTab: "servers", + createdAt: 123, + }); + + render( + , + ); + + expect(screen.queryByText("Connecting Linear...")).not.toBeInTheDocument(); + expect(screen.queryByText("Quick Connect")).not.toBeInTheDocument(); + expect(localStorage.getItem("mcp-quick-connect-pending")).toBeNull(); + }); }); diff --git a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx index 146ca33c9..8d9b525b4 100644 --- a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx +++ b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx @@ -108,8 +108,12 @@ export function ServerConnectionCard({ const [showTunnelExplanation, setShowTunnelExplanation] = useState(false); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); - const { label: connectionStatusLabel, indicatorColor } = - getConnectionStatusMeta(server.connectionStatus); + const { + label: connectionStatusLabel, + indicatorColor, + Icon: ConnectionStatusIcon, + iconClassName, + } = getConnectionStatusMeta(server.connectionStatus); const commandDisplay = getServerCommandDisplay(server.config); const initializationInfo = server.initializationInfo; @@ -126,10 +130,12 @@ export function ServerConnectionCard({ const hasError = server.connectionStatus === "failed" && Boolean(server.lastError); const isHostedHttpReconnectBlocked = isHostedInsecureHttpServer(server); - const isReconnectMenuDisabled = - isReconnecting || + const isPendingConnection = server.connectionStatus === "connecting" || server.connectionStatus === "oauth-flow"; + const isReconnectMenuDisabled = + isReconnecting || + isPendingConnection; const isStdioServer = "command" in server.config; const isInsecureHttpServer = "url" in server.config && @@ -412,10 +418,14 @@ export function ServerConnectionCard({ onClick={(e) => e.stopPropagation()} > - + {isPendingConnection ? ( + + ) : ( + + )} {server.connectionStatus === "failed" ? `${connectionStatusLabel} (${server.retryCount})` @@ -584,6 +594,25 @@ export function ServerConnectionCard({ + {server.connectionStatus === "oauth-flow" && ( +
e.stopPropagation()} + > + Complete sign-in in the browser. Inspector will resume + automatically. +
+ )} + + {server.connectionStatus === "connecting" && ( +
e.stopPropagation()} + > + Authorization complete. Finalizing the MCP connection. +
+ )} +
{ const server = createServer({ connectionStatus: "connecting" }); render(); - expect(screen.getByText("Connecting...")).toBeInTheDocument(); + expect(screen.getByText("Finishing setup...")).toBeInTheDocument(); + expect( + screen.getByText("Authorization complete. Finalizing the MCP connection."), + ).toBeInTheDocument(); + }); + + it("shows oauth browser authorization state", () => { + const server = createServer({ + connectionStatus: "oauth-flow", + useOAuth: true, + }); + render(); + + expect( + screen.getByText("Authorizing in browser..."), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Complete sign-in in the browser. Inspector will resume automatically.", + ), + ).toBeInTheDocument(); }); it("shows failed status with retry count", () => { diff --git a/mcpjam-inspector/client/src/components/connection/__tests__/server-card-utils.test.ts b/mcpjam-inspector/client/src/components/connection/__tests__/server-card-utils.test.ts index 6f81d609c..5faacd578 100644 --- a/mcpjam-inspector/client/src/components/connection/__tests__/server-card-utils.test.ts +++ b/mcpjam-inspector/client/src/components/connection/__tests__/server-card-utils.test.ts @@ -17,14 +17,14 @@ describe("getConnectionStatusMeta", () => { it("returns connecting status meta with spinner", () => { const meta = getConnectionStatusMeta("connecting"); - expect(meta.label).toBe("Connecting..."); + expect(meta.label).toBe("Finishing setup..."); expect(meta.indicatorColor).toBe("#3b82f6"); expect(meta.iconClassName).toContain("animate-spin"); }); it("returns oauth-flow status meta", () => { const meta = getConnectionStatusMeta("oauth-flow"); - expect(meta.label).toBe("Authorizing..."); + expect(meta.label).toBe("Authorizing in browser..."); expect(meta.indicatorColor).toBe("#a855f7"); expect(meta.iconClassName).toContain("text-purple-500"); }); @@ -53,7 +53,7 @@ describe("getConnectionStatusMeta", () => { describe("getServerCommandDisplay", () => { it("returns URL for HTTP/SSE config", () => { const config: MCPServerConfig = { - url: new URL("http://localhost:3000/mcp"), + url: "http://localhost:3000/mcp", }; expect(getServerCommandDisplay(config)).toBe("http://localhost:3000/mcp"); }); @@ -84,7 +84,7 @@ describe("getServerCommandDisplay", () => { }); it("handles empty config gracefully", () => { - const config: MCPServerConfig = {}; + const config = {} as MCPServerConfig; expect(getServerCommandDisplay(config)).toBe(""); }); @@ -100,7 +100,7 @@ describe("getServerCommandDisplay", () => { describe("getServerTransportLabel", () => { it('returns "HTTP/SSE" for URL config', () => { const config: MCPServerConfig = { - url: new URL("http://localhost:3000"), + url: "http://localhost:3000", }; expect(getServerTransportLabel(config)).toBe("HTTP/SSE"); }); @@ -114,7 +114,7 @@ describe("getServerTransportLabel", () => { }); it('returns "STDIO" for empty config', () => { - const config: MCPServerConfig = {}; + const config = {} as MCPServerConfig; expect(getServerTransportLabel(config)).toBe("STDIO"); }); }); diff --git a/mcpjam-inspector/client/src/components/connection/server-card-utils.ts b/mcpjam-inspector/client/src/components/connection/server-card-utils.ts index 4d453fe48..dfd6a9507 100644 --- a/mcpjam-inspector/client/src/components/connection/server-card-utils.ts +++ b/mcpjam-inspector/client/src/components/connection/server-card-utils.ts @@ -18,13 +18,13 @@ const connectionStatusMeta: Record = { iconClassName: "h-3 w-3 text-green-500", }, connecting: { - label: "Connecting...", + label: "Finishing setup...", indicatorColor: "#3b82f6", Icon: Loader2, iconClassName: "h-3 w-3 text-blue-500 animate-spin", }, "oauth-flow": { - label: "Authorizing...", + label: "Authorizing in browser...", indicatorColor: "#a855f7", Icon: Loader2, iconClassName: "h-3 w-3 text-purple-500 animate-spin", diff --git a/mcpjam-inspector/client/src/lib/__tests__/quick-connect-pending.test.ts b/mcpjam-inspector/client/src/lib/__tests__/quick-connect-pending.test.ts new file mode 100644 index 000000000..c633ac44d --- /dev/null +++ b/mcpjam-inspector/client/src/lib/__tests__/quick-connect-pending.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + clearPendingQuickConnect, + readPendingQuickConnect, + writePendingQuickConnect, +} from "../quick-connect-pending"; + +describe("quick-connect-pending", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("writes and reads structured pending quick-connect state", () => { + writePendingQuickConnect({ + serverName: "Linear", + registryServerId: "linear-1", + displayName: "Linear", + sourceTab: "servers", + createdAt: 123, + }); + + expect(readPendingQuickConnect()).toEqual({ + serverName: "Linear", + registryServerId: "linear-1", + displayName: "Linear", + sourceTab: "servers", + createdAt: 123, + }); + }); + + it("falls back from the legacy registry pending redirect key", () => { + localStorage.setItem("registry-pending-redirect", "Linear"); + + expect(readPendingQuickConnect()).toEqual({ + serverName: "Linear", + displayName: "Linear", + sourceTab: "registry", + createdAt: expect.any(Number), + }); + }); + + it("returns null for malformed structured state", () => { + localStorage.setItem("mcp-quick-connect-pending", "{not-json"); + + expect(readPendingQuickConnect()).toBeNull(); + }); + + it("clears both structured and legacy state", () => { + writePendingQuickConnect({ + serverName: "Linear", + registryServerId: "linear-1", + displayName: "Linear", + sourceTab: "registry", + createdAt: 123, + }); + localStorage.setItem("registry-pending-redirect", "Linear"); + + clearPendingQuickConnect(); + + expect(localStorage.getItem("mcp-quick-connect-pending")).toBeNull(); + expect(localStorage.getItem("registry-pending-redirect")).toBeNull(); + expect(readPendingQuickConnect()).toBeNull(); + }); +}); diff --git a/mcpjam-inspector/client/src/lib/quick-connect-pending.ts b/mcpjam-inspector/client/src/lib/quick-connect-pending.ts new file mode 100644 index 000000000..80967c14c --- /dev/null +++ b/mcpjam-inspector/client/src/lib/quick-connect-pending.ts @@ -0,0 +1,67 @@ +export type QuickConnectSourceTab = "servers" | "registry"; + +export interface PendingQuickConnectState { + serverName: string; + displayName: string; + sourceTab: QuickConnectSourceTab; + createdAt: number; + registryServerId?: string; +} + +const STORAGE_KEY = "mcp-quick-connect-pending"; +const LEGACY_STORAGE_KEY = "registry-pending-redirect"; + +function isSourceTab(value: unknown): value is QuickConnectSourceTab { + return value === "servers" || value === "registry"; +} + +export function readPendingQuickConnect(): PendingQuickConnectState | null { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + const parsed = JSON.parse(stored) as Partial; + if ( + typeof parsed.serverName === "string" && + typeof parsed.displayName === "string" && + typeof parsed.createdAt === "number" && + isSourceTab(parsed.sourceTab) + ) { + return { + serverName: parsed.serverName, + displayName: parsed.displayName, + sourceTab: parsed.sourceTab, + createdAt: parsed.createdAt, + registryServerId: + typeof parsed.registryServerId === "string" + ? parsed.registryServerId + : undefined, + }; + } + } catch { + return null; + } + } + + const legacyServerName = localStorage.getItem(LEGACY_STORAGE_KEY); + if (!legacyServerName) { + return null; + } + + return { + serverName: legacyServerName, + displayName: legacyServerName, + sourceTab: "registry", + createdAt: Date.now(), + }; +} + +export function writePendingQuickConnect( + state: PendingQuickConnectState, +): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +} + +export function clearPendingQuickConnect(): void { + localStorage.removeItem(STORAGE_KEY); + localStorage.removeItem(LEGACY_STORAGE_KEY); +} From a68fee16ef8ab5f8ed29fb7b43a26437e960f7d7 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Tue, 24 Mar 2026 19:57:29 -0700 Subject: [PATCH 12/21] Network icon --- .../client/src/components/__tests__/ServersTab.test.tsx | 6 +++--- mcpjam-inspector/client/src/components/mcp-sidebar.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index 0039404aa..554d8f679 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -549,7 +549,7 @@ describe("ServersTab shared detail modal", () => { ); expect(screen.getByText("Quick Connect")).toBeInTheDocument(); expect(screen.getByText("Connecting Linear...")).toBeInTheDocument(); - expect(screen.getByText("Authorizing...")).toBeInTheDocument(); + expect(screen.getAllByText("Authorizing...").length).toBeGreaterThanOrEqual(1); expect(screen.getByLabelText("Connect Linear")).toBeDisabled(); }); @@ -599,7 +599,7 @@ describe("ServersTab shared detail modal", () => { expect(screen.getByText("Quick Connect")).toBeInTheDocument(); expect(screen.getByText("Connecting Linear...")).toBeInTheDocument(); - expect(screen.getByText("Authorizing...")).toBeInTheDocument(); + expect(screen.getAllByText("Authorizing...").length).toBeGreaterThanOrEqual(1); expect(screen.getByTestId("server-card-Linear")).toHaveTextContent( "Linear:oauth-flow", ); @@ -649,7 +649,7 @@ describe("ServersTab shared detail modal", () => { />, ); - expect(screen.getByText("Finishing setup...")).toBeInTheDocument(); + expect(screen.getAllByText("Finishing setup...").length).toBeGreaterThanOrEqual(1); expect(screen.getByTestId("server-card-Linear")).toHaveTextContent( "Linear:connecting", ); diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx index 40a356143..e144a3e69 100644 --- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx +++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx @@ -16,7 +16,7 @@ import { GitBranch, GraduationCap, Box, - Package, + Network, } from "lucide-react"; import { usePostHog, useFeatureFlagEnabled } from "posthog-js/react"; @@ -137,7 +137,7 @@ const navigationSections: NavSection[] = [ { title: "Registry", url: "#registry", - icon: Package, + icon: Network, }, { title: "Chat", From 246ebd0964548212b0a8f672bbac3c0a453953cb Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Tue, 24 Mar 2026 20:04:53 -0700 Subject: [PATCH 13/21] layoutgrid --- mcpjam-inspector/client/src/components/mcp-sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx index e144a3e69..8c69cb2fb 100644 --- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx +++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx @@ -16,7 +16,7 @@ import { GitBranch, GraduationCap, Box, - Network, + LayoutGrid, } from "lucide-react"; import { usePostHog, useFeatureFlagEnabled } from "posthog-js/react"; @@ -137,7 +137,7 @@ const navigationSections: NavSection[] = [ { title: "Registry", url: "#registry", - icon: Network, + icon: LayoutGrid, }, { title: "Chat", From f063e11b4ff08687732cf2f59d15aca83f155775 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Tue, 24 Mar 2026 20:26:47 -0700 Subject: [PATCH 14/21] quickly --- mcpjam-inspector/client/src/components/RegistryTab.tsx | 2 +- .../client/src/components/__tests__/RegistryTab.test.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index 5feb1661d..da22e4c8c 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -159,7 +159,7 @@ export function RegistryTab({

Registry

- Pre-configured MCP servers you can connect with one click. + Pre-configured MCP servers you can connect quickly.

diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index e29665430..8a8c93b2b 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -130,7 +130,7 @@ describe("RegistryTab", () => { expect(screen.getByText("Registry")).toBeInTheDocument(); expect( screen.getByText( - "Pre-configured MCP servers you can connect with one click.", + "Pre-configured MCP servers you can connect quickly.", ), ).toBeInTheDocument(); }); From e35ba74780b4fc5d44b0fbc18da608ec1bd433ea Mon Sep 17 00:00:00 2001 From: prathmeshpatel <25394100+prathmeshpatel@users.noreply.github.com> Date: Wed, 25 Mar 2026 03:27:58 +0000 Subject: [PATCH 15/21] style: auto-fix prettier formatting --- mcpjam-inspector/client/src/App.tsx | 4 +- .../client/src/components/RegistryTab.tsx | 10 +- .../client/src/components/ServersTab.tsx | 6 +- .../components/__tests__/RegistryTab.test.tsx | 85 ++++++-- .../components/__tests__/ServersTab.test.tsx | 38 ++-- .../connection/ServerConnectionCard.tsx | 4 +- .../__tests__/ServerConnectionCard.test.tsx | 8 +- .../__tests__/consolidateServers.test.ts | 12 +- .../__tests__/useRegistryServers.test.tsx | 15 +- .../src/hooks/hosted/use-hosted-oauth-gate.ts | 8 +- .../client/src/hooks/use-server-state.ts | 16 +- .../client/src/hooks/useRegistryServers.ts | 6 +- .../client/src/lib/hosted-oauth-callback.ts | 5 +- .../src/lib/oauth/__tests__/mcp-oauth.test.ts | 192 ++++++++++-------- .../client/src/lib/oauth/mcp-oauth.ts | 77 +++++-- 15 files changed, 316 insertions(+), 170 deletions(-) diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index dcd3d7a5e..1361b1f7d 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -235,7 +235,9 @@ export default function App() { } clearHostedOAuthPendingState(); - console.log("[OAuthDebug] REMOVE mcp-oauth-pending (App.tsx handleOAuthError)"); // ##TODOClean + console.log( + "[OAuthDebug] REMOVE mcp-oauth-pending (App.tsx handleOAuthError)", + ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); localStorage.removeItem("mcp-oauth-return-hash"); const returnHash = resolveHostedOAuthReturnHash(callbackContext); diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index da22e4c8c..7f47a32bf 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -63,15 +63,14 @@ export function RegistryTab({ const [pendingQuickConnect, setPendingQuickConnect] = useState(() => readPendingQuickConnect()); - const { registryServers, isLoading, connect, disconnect } = useRegistryServers( - { + const { registryServers, isLoading, connect, disconnect } = + useRegistryServers({ workspaceId, isAuthenticated, liveServers: servers, onConnect, onDisconnect, - }, - ); + }); // Auto-redirect to App Builder when a pending server becomes connected. // We persist in localStorage to survive OAuth redirects (page remounts). @@ -366,7 +365,8 @@ function DualTypeAction({ onDisconnect(activeVariant)}> - {disconnectLabel} {activeVariant.clientType === "app" ? "App" : "Text"} + {disconnectLabel}{" "} + {activeVariant.clientType === "app" ? "App" : "Text"} diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index d7d9f4ca2..2ae06ff1f 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -760,7 +760,8 @@ export function ServersTab({ onRemove(serverName); }} hostedServerId={ - sharedWorkspaceServersRecord[pendingQuickConnectServer.name]?._id + sharedWorkspaceServersRecord[pendingQuickConnectServer.name] + ?._id } onOpenDetailModal={handleOpenDetailModal} /> @@ -801,7 +802,8 @@ export function ServersTab({
{isLoadingWorkspaces ? renderLoadingContent() - : hasConnectedServers || (hasAnyServers && !isPendingQuickConnectVisible) + : hasConnectedServers || + (hasAnyServers && !isPendingQuickConnectVisible) ? renderConnectedContent() : renderEmptyContent()} diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index 8a8c93b2b..83936c445 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -129,9 +129,7 @@ describe("RegistryTab", () => { expect(screen.getByText("Registry")).toBeInTheDocument(); expect( - screen.getByText( - "Pre-configured MCP servers you can connect quickly.", - ), + screen.getByText("Pre-configured MCP servers you can connect quickly."), ).toBeInTheDocument(); }); }); @@ -513,7 +511,10 @@ describe("RegistryTab", () => { describe("consolidated cards — dual-type servers", () => { function createFullServer( - overrides: Partial & { _id: string; displayName: string }, + overrides: Partial & { + _id: string; + displayName: string; + }, ): EnrichedRegistryServer { return { name: `com.test.${overrides.displayName.toLowerCase()}`, @@ -539,9 +540,21 @@ describe("RegistryTab", () => { it("renders one card per consolidated server (dual-type = 1 card)", () => { mockHookReturn = { registryServers: [ - createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), - createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), - createFullServer({ _id: "linear-1", displayName: "Linear", clientType: "text" }), + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), + createFullServer({ + _id: "linear-1", + displayName: "Linear", + clientType: "text", + }), ], categories: ["Productivity"], isLoading: false, @@ -561,8 +574,16 @@ describe("RegistryTab", () => { it("shows both Text and App badges on dual-type card", () => { mockHookReturn = { registryServers: [ - createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), - createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), ], categories: ["Productivity"], isLoading: false, @@ -579,8 +600,16 @@ describe("RegistryTab", () => { it("shows dropdown trigger for dual-type card", () => { mockHookReturn = { registryServers: [ - createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), - createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), ], categories: ["Productivity"], isLoading: false, @@ -590,13 +619,19 @@ describe("RegistryTab", () => { render(); - expect(screen.getByTestId("connect-dropdown-trigger")).toBeInTheDocument(); + expect( + screen.getByTestId("connect-dropdown-trigger"), + ).toBeInTheDocument(); }); it("does not show dropdown trigger for single-type card", () => { mockHookReturn = { registryServers: [ - createFullServer({ _id: "linear-1", displayName: "Linear", clientType: "text" }), + createFullServer({ + _id: "linear-1", + displayName: "Linear", + clientType: "text", + }), ], categories: ["Productivity"], isLoading: false, @@ -612,8 +647,16 @@ describe("RegistryTab", () => { it("dropdown contains Connect as Text and Connect as App options", async () => { mockHookReturn = { registryServers: [ - createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), - createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), ], categories: ["Productivity"], isLoading: false, @@ -633,8 +676,16 @@ describe("RegistryTab", () => { it("stores the suffixed runtime name when connecting a dual-type variant", async () => { mockHookReturn = { registryServers: [ - createFullServer({ _id: "asana-text", displayName: "Asana", clientType: "text" }), - createFullServer({ _id: "asana-app", displayName: "Asana", clientType: "app" }), + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), ], categories: ["Productivity"], isLoading: false, diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index 554d8f679..2b5c368b2 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -13,18 +13,20 @@ import { import { writePendingQuickConnect } from "@/lib/quick-connect-pending"; let mockIsAuthenticated = false; -let mockRegistryServers: Array<{ - _id: string; - displayName: string; - publisher?: string; - transport: { - transportType: "http" | "stdio"; - url?: string; - useOAuth?: boolean; - oauthScopes?: string[]; - oauthCredentialKey?: string; - }; -}> | undefined; +let mockRegistryServers: + | Array<{ + _id: string; + displayName: string; + publisher?: string; + transport: { + transportType: "http" | "stdio"; + url?: string; + useOAuth?: boolean; + oauthScopes?: string[]; + oauthCredentialKey?: string; + }; + }> + | undefined; vi.mock("posthog-js/react", () => ({ usePostHog: () => ({ @@ -549,7 +551,9 @@ describe("ServersTab shared detail modal", () => { ); expect(screen.getByText("Quick Connect")).toBeInTheDocument(); expect(screen.getByText("Connecting Linear...")).toBeInTheDocument(); - expect(screen.getAllByText("Authorizing...").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Authorizing...").length).toBeGreaterThanOrEqual( + 1, + ); expect(screen.getByLabelText("Connect Linear")).toBeDisabled(); }); @@ -599,7 +603,9 @@ describe("ServersTab shared detail modal", () => { expect(screen.getByText("Quick Connect")).toBeInTheDocument(); expect(screen.getByText("Connecting Linear...")).toBeInTheDocument(); - expect(screen.getAllByText("Authorizing...").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Authorizing...").length).toBeGreaterThanOrEqual( + 1, + ); expect(screen.getByTestId("server-card-Linear")).toHaveTextContent( "Linear:oauth-flow", ); @@ -649,7 +655,9 @@ describe("ServersTab shared detail modal", () => { />, ); - expect(screen.getAllByText("Finishing setup...").length).toBeGreaterThanOrEqual(1); + expect( + screen.getAllByText("Finishing setup...").length, + ).toBeGreaterThanOrEqual(1); expect(screen.getByTestId("server-card-Linear")).toHaveTextContent( "Linear:connecting", ); diff --git a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx index 8d9b525b4..3b7bc781a 100644 --- a/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx +++ b/mcpjam-inspector/client/src/components/connection/ServerConnectionCard.tsx @@ -133,9 +133,7 @@ export function ServerConnectionCard({ const isPendingConnection = server.connectionStatus === "connecting" || server.connectionStatus === "oauth-flow"; - const isReconnectMenuDisabled = - isReconnecting || - isPendingConnection; + const isReconnectMenuDisabled = isReconnecting || isPendingConnection; const isStdioServer = "command" in server.config; const isInsecureHttpServer = "url" in server.config && diff --git a/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.test.tsx b/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.test.tsx index 9576d8944..55e9ad8b5 100644 --- a/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.test.tsx +++ b/mcpjam-inspector/client/src/components/connection/__tests__/ServerConnectionCard.test.tsx @@ -152,7 +152,9 @@ describe("ServerConnectionCard", () => { expect(screen.getByText("Finishing setup...")).toBeInTheDocument(); expect( - screen.getByText("Authorization complete. Finalizing the MCP connection."), + screen.getByText( + "Authorization complete. Finalizing the MCP connection.", + ), ).toBeInTheDocument(); }); @@ -163,9 +165,7 @@ describe("ServerConnectionCard", () => { }); render(); - expect( - screen.getByText("Authorizing in browser..."), - ).toBeInTheDocument(); + expect(screen.getByText("Authorizing in browser...")).toBeInTheDocument(); expect( screen.getByText( "Complete sign-in in the browser. Inspector will resume automatically.", diff --git a/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts b/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts index 177da84d4..809fb053e 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts +++ b/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts @@ -95,15 +95,21 @@ describe("consolidateServers", () => { expect(result).toHaveLength(3); - const asanaGroup = result.find((c) => c.variants[0].displayName === "Asana"); + const asanaGroup = result.find( + (c) => c.variants[0].displayName === "Asana", + ); expect(asanaGroup?.hasDualType).toBe(true); expect(asanaGroup?.variants).toHaveLength(2); - const linearGroup = result.find((c) => c.variants[0].displayName === "Linear"); + const linearGroup = result.find( + (c) => c.variants[0].displayName === "Linear", + ); expect(linearGroup?.hasDualType).toBe(false); expect(linearGroup?.variants).toHaveLength(1); - const notionGroup = result.find((c) => c.variants[0].displayName === "Notion"); + const notionGroup = result.find( + (c) => c.variants[0].displayName === "Notion", + ); expect(notionGroup?.hasDualType).toBe(false); expect(notionGroup?.variants).toHaveLength(1); }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx index fe4ba99c8..571a2c5cc 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx @@ -6,15 +6,12 @@ import { useRegistryServers, } from "../useRegistryServers"; -const { - mockUseQuery, - mockConnectMutation, - mockDisconnectMutation, -} = vi.hoisted(() => ({ - mockUseQuery: vi.fn(), - mockConnectMutation: vi.fn(), - mockDisconnectMutation: vi.fn(), -})); +const { mockUseQuery, mockConnectMutation, mockDisconnectMutation } = + vi.hoisted(() => ({ + mockUseQuery: vi.fn(), + mockConnectMutation: vi.fn(), + mockDisconnectMutation: vi.fn(), + })); vi.mock("convex/react", () => ({ useQuery: (...args: unknown[]) => mockUseQuery(...args), diff --git a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts index 7fc8d4fdf..b84248a38 100644 --- a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts +++ b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts @@ -373,7 +373,9 @@ export function useHostedOAuthGate({ if (!result.success) { clearHostedOAuthPendingState(); - console.log("[OAuthDebug] REMOVE mcp-oauth-pending (use-hosted-oauth-gate failure)"); // ##TODOClean + console.log( + "[OAuthDebug] REMOVE mcp-oauth-pending (use-hosted-oauth-gate failure)", + ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); localStorage.removeItem("mcp-oauth-return-hash"); localStorage.removeItem(pendingKey); @@ -399,7 +401,9 @@ export function useHostedOAuthGate({ if (accessToken) { clearHostedOAuthPendingState(); - console.log("[OAuthDebug] REMOVE mcp-oauth-pending (use-hosted-oauth-gate success)"); // ##TODOClean + console.log( + "[OAuthDebug] REMOVE mcp-oauth-pending (use-hosted-oauth-gate success)", + ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); localStorage.removeItem("mcp-oauth-return-hash"); localStorage.removeItem(pendingKey); diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index 0576b091e..dceb5e026 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -154,7 +154,12 @@ export function useServerState({ const failPendingOAuthConnection = useCallback( (errorMessage: string) => { const pendingServerName = localStorage.getItem("mcp-oauth-pending"); - console.log("[OAuthDebug] failPendingOAuthConnection:", pendingServerName, "error:", errorMessage); // ##TODOClean + console.log( + "[OAuthDebug] failPendingOAuthConnection:", + pendingServerName, + "error:", + errorMessage, + ); // ##TODOClean if (pendingServerName) { dispatch({ type: "CONNECT_FAILURE", @@ -164,7 +169,9 @@ export function useServerState({ } localStorage.removeItem("mcp-oauth-return-hash"); - console.log("[OAuthDebug] REMOVE mcp-oauth-pending (failPendingOAuthConnection)"); // ##TODOClean + console.log( + "[OAuthDebug] REMOVE mcp-oauth-pending (failPendingOAuthConnection)", + ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); return pendingServerName; @@ -540,7 +547,10 @@ export function useServerState({ const handleOAuthCallbackComplete = useCallback( async (code: string) => { const pendingServerName = localStorage.getItem("mcp-oauth-pending"); - console.log("[OAuthDebug] handleOAuthCallbackComplete: mcp-oauth-pending =", pendingServerName); // ##TODOClean + console.log( + "[OAuthDebug] handleOAuthCallbackComplete: mcp-oauth-pending =", + pendingServerName, + ); // ##TODOClean try { const result = await handleOAuthCallback(code); diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index 36a070e9b..5a1a910c3 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -379,9 +379,9 @@ export function useRegistryServers({ }, [enrichedServers]); // Track registry server IDs that are pending connection (waiting for OAuth / handshake) - const [pendingServerIds, setPendingServerIds] = useState< - Map - >(new Map()); // registryServerId → suffixed server name + const [pendingServerIds, setPendingServerIds] = useState>( + new Map(), + ); // registryServerId → suffixed server name // Record the Convex connection only after the server actually connects useEffect(() => { diff --git a/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts b/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts index c239000d3..ff0f9548d 100644 --- a/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts +++ b/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts @@ -198,7 +198,10 @@ export function getHostedOAuthCallbackContext(): HostedOAuthCallbackContext | nu } const serverName = localStorage.getItem("mcp-oauth-pending")?.trim() ?? ""; - console.log("[OAuthDebug] hosted-oauth-callback: mcp-oauth-pending =", serverName || "(empty)"); // ##TODOClean + console.log( + "[OAuthDebug] hosted-oauth-callback: mcp-oauth-pending =", + serverName || "(empty)", + ); // ##TODOClean if (!serverName) { return null; } diff --git a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts index 1c01a8e11..2c3541273 100644 --- a/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts +++ b/mcpjam-inspector/client/src/lib/oauth/__tests__/mcp-oauth.test.ts @@ -550,25 +550,27 @@ describe("mcp-oauth", () => { const discoveryState = createAsanaDiscoveryState(); await seedPendingOAuth("registry-asana", discoveryState, true); - mockFetchToken.mockImplementationOnce(async (provider, authServerUrl, options) => { - expect(authServerUrl).toBe("https://app.asana.com"); - expect(options?.metadata?.token_endpoint).toBe( - "https://app.asana.com/-/oauth_token", - ); - const response = await options!.fetchFn!( - "https://app.asana.com/-/oauth_token", - { - method: "POST", - body: new URLSearchParams({ - grant_type: "authorization_code", - code: options!.authorizationCode!, - code_verifier: provider.codeVerifier(), - redirect_uri: String(provider.redirectUrl), - }), - }, - ); - return await response.json(); - }); + mockFetchToken.mockImplementationOnce( + async (provider, authServerUrl, options) => { + expect(authServerUrl).toBe("https://app.asana.com"); + expect(options?.metadata?.token_endpoint).toBe( + "https://app.asana.com/-/oauth_token", + ); + const response = await options!.fetchFn!( + "https://app.asana.com/-/oauth_token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + return await response.json(); + }, + ); const { handleOAuthCallback } = await import("../mcp-oauth"); const callbackResult = await handleOAuthCallback("oauth-code"); @@ -595,7 +597,11 @@ describe("mcp-oauth", () => { }); it("persists preregistered registry routing for fresh Asana OAuth connects", async () => { - await seedPendingOAuth("registry-asana", createAsanaDiscoveryState(), true); + await seedPendingOAuth( + "registry-asana", + createAsanaDiscoveryState(), + true, + ); expect(localStorage.getItem("mcp-oauth-config-asana")).toBe( JSON.stringify({ @@ -641,7 +647,10 @@ describe("mcp-oauth", () => { return "AUTHORIZED"; }); - localStorage.setItem("mcp-serverUrl-asana", "https://mcp.asana.com/v2/mcp"); + localStorage.setItem( + "mcp-serverUrl-asana", + "https://mcp.asana.com/v2/mcp", + ); localStorage.setItem( "mcp-oauth-config-asana", JSON.stringify({ @@ -702,25 +711,27 @@ describe("mcp-oauth", () => { }); await seedPendingOAuth("registry-asana", undefined, true); - mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { - const response = await options!.fetchFn!( - "https://app.asana.com/-/oauth_token", - { - method: "POST", - body: new URLSearchParams({ - grant_type: "authorization_code", - code: options!.authorizationCode!, - code_verifier: provider.codeVerifier(), - redirect_uri: String(provider.redirectUrl), - }), - }, - ); - if (!response.ok) { - const payload = await response.json(); - throw new Error(`${payload.error}: ${payload.error_description}`); - } - return await response.json(); - }); + mockFetchToken.mockImplementationOnce( + async (provider, _authServerUrl, options) => { + const response = await options!.fetchFn!( + "https://app.asana.com/-/oauth_token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + if (!response.ok) { + const payload = await response.json(); + throw new Error(`${payload.error}: ${payload.error_description}`); + } + return await response.json(); + }, + ); const { handleOAuthCallback } = await import("../mcp-oauth"); const callbackResult = await handleOAuthCallback("oauth-code"); @@ -749,21 +760,23 @@ describe("mcp-oauth", () => { }, }), ); - mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { - const response = await options!.fetchFn!( - "https://app.asana.com/-/oauth_token", - { - method: "POST", - body: new URLSearchParams({ - grant_type: "authorization_code", - code: options!.authorizationCode!, - code_verifier: provider.codeVerifier(), - redirect_uri: String(provider.redirectUrl), - }), - }, - ); - return await response.json(); - }); + mockFetchToken.mockImplementationOnce( + async (provider, _authServerUrl, options) => { + const response = await options!.fetchFn!( + "https://app.asana.com/-/oauth_token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + return await response.json(); + }, + ); const { handleOAuthCallback } = await import("../mcp-oauth"); const callbackResult = await handleOAuthCallback("oauth-code"); @@ -812,21 +825,23 @@ describe("mcp-oauth", () => { }, }), ); - mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { - const response = await options!.fetchFn!( - "https://app.asana.com/-/oauth_token", - { - method: "POST", - body: new URLSearchParams({ - grant_type: "authorization_code", - code: options!.authorizationCode!, - code_verifier: provider.codeVerifier(), - redirect_uri: String(provider.redirectUrl), - }), - }, - ); - return await response.json(); - }); + mockFetchToken.mockImplementationOnce( + async (provider, _authServerUrl, options) => { + const response = await options!.fetchFn!( + "https://app.asana.com/-/oauth_token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + return await response.json(); + }, + ); const { handleOAuthCallback } = await import("../mcp-oauth"); const callbackResult = await handleOAuthCallback("oauth-code"); @@ -879,21 +894,23 @@ describe("mcp-oauth", () => { }, }), ); - mockFetchToken.mockImplementationOnce(async (provider, _authServerUrl, options) => { - const response = await options!.fetchFn!( - "https://mcp.linear.app/token", - { - method: "POST", - body: new URLSearchParams({ - grant_type: "authorization_code", - code: options!.authorizationCode!, - code_verifier: provider.codeVerifier(), - redirect_uri: String(provider.redirectUrl), - }), - }, - ); - return await response.json(); - }); + mockFetchToken.mockImplementationOnce( + async (provider, _authServerUrl, options) => { + const response = await options!.fetchFn!( + "https://mcp.linear.app/token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + code: options!.authorizationCode!, + code_verifier: provider.codeVerifier(), + redirect_uri: String(provider.redirectUrl), + }), + }, + ); + return await response.json(); + }, + ); const { handleOAuthCallback } = await import("../mcp-oauth"); const callbackResult = await handleOAuthCallback("oauth-code"); @@ -954,7 +971,10 @@ describe("mcp-oauth", () => { return "AUTHORIZED"; }); - localStorage.setItem("mcp-serverUrl-linear", "https://mcp.linear.app/mcp"); + localStorage.setItem( + "mcp-serverUrl-linear", + "https://mcp.linear.app/mcp", + ); localStorage.setItem( "mcp-oauth-config-linear", JSON.stringify({ registryServerId: "registry-linear" }), diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index 41e2452cd..1338a9e4d 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -137,7 +137,9 @@ export function buildStoredOAuthConfig( return config; } -function parseOAuthRequestFields(body: unknown): OAuthRequestFields | undefined { +function parseOAuthRequestFields( + body: unknown, +): OAuthRequestFields | undefined { if (!body) return undefined; if (typeof body === "string") { @@ -152,7 +154,11 @@ function parseOAuthRequestFields(body: unknown): OAuthRequestFields | undefined ) { try { const parsed = JSON.parse(trimmed); - if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { const entries = Object.entries(parsed).flatMap(([key, value]) => { if (typeof value === "string") { return [[key, value] as const]; @@ -311,7 +317,9 @@ function createOAuthFetchInterceptor( init?: RequestInit, ): Promise { const method = (init?.method || "GET").toUpperCase(); - const serializedBody = init?.body ? await serializeBody(init.body) : undefined; + const serializedBody = init?.body + ? await serializeBody(init.body) + : undefined; const url = typeof input === "string" ? input @@ -610,7 +618,11 @@ export class MCPOAuthProvider implements OAuthClientProvider { captureServerDetailModalOAuthResume(this.serverName); // Store server name for callback recovery localStorage.setItem("mcp-oauth-pending", this.serverName); - console.log("[OAuthDebug] SET mcp-oauth-pending =", this.serverName, "(redirectToAuthorization)"); // ##TODOClean + console.log( + "[OAuthDebug] SET mcp-oauth-pending =", + this.serverName, + "(redirectToAuthorization)", + ); // ##TODOClean // Store current hash to restore after OAuth callback if (window.location.hash) { localStorage.setItem("mcp-oauth-return-hash", window.location.hash); @@ -625,7 +637,12 @@ export class MCPOAuthProvider implements OAuthClientProvider { codeVerifier(): string { const verifier = localStorage.getItem(`mcp-verifier-${this.serverName}`); - console.log("[OAuthDebug] READ verifier for", this.serverName, "exists:", !!verifier); // ##TODOClean + console.log( + "[OAuthDebug] READ verifier for", + this.serverName, + "exists:", + !!verifier, + ); // ##TODOClean if (!verifier) { throw new Error("Code verifier not found"); } @@ -635,7 +652,13 @@ export class MCPOAuthProvider implements OAuthClientProvider { async invalidateCredentials( scope: "all" | "client" | "tokens" | "verifier" | "discovery", ) { - console.log("[OAuthDebug] invalidateCredentials:", scope, "for", this.serverName, new Error().stack); // ##TODOClean + console.log( + "[OAuthDebug] invalidateCredentials:", + scope, + "for", + this.serverName, + new Error().stack, + ); // ##TODOClean switch (scope) { case "all": localStorage.removeItem(`mcp-tokens-${this.serverName}`); @@ -685,7 +708,15 @@ export async function initiateOAuth( options.serverUrl, ); localStorage.setItem("mcp-oauth-pending", options.serverName); - console.log("[OAuthDebug] SET mcp-oauth-pending =", options.serverName, "registryServerId:", options.registryServerId, "clientId:", options.clientId, "(initiateOAuth)"); // ##TODOClean + console.log( + "[OAuthDebug] SET mcp-oauth-pending =", + options.serverName, + "registryServerId:", + options.registryServerId, + "clientId:", + options.clientId, + "(initiateOAuth)", + ); // ##TODOClean // Store OAuth configuration (scopes, registryServerId) for recovery if connection fails const oauthConfig = buildStoredOAuthConfig(options); @@ -784,11 +815,19 @@ export async function handleOAuthCallback( ): Promise { // Get pending server name from localStorage (needed before creating interceptor) const serverName = localStorage.getItem("mcp-oauth-pending"); - console.log("[OAuthDebug] handleOAuthCallback: mcp-oauth-pending =", serverName); // ##TODOClean + console.log( + "[OAuthDebug] handleOAuthCallback: mcp-oauth-pending =", + serverName, + ); // ##TODOClean // Read registryServerId from stored OAuth config if present const oauthConfig = readStoredOAuthConfig(serverName); - console.log("[OAuthDebug] handleOAuthCallback: registryServerId =", oauthConfig.registryServerId, "oauthConfig =", localStorage.getItem(`mcp-oauth-config-${serverName}`)); // ##TODOClean + console.log( + "[OAuthDebug] handleOAuthCallback: registryServerId =", + oauthConfig.registryServerId, + "oauthConfig =", + localStorage.getItem(`mcp-oauth-config-${serverName}`), + ); // ##TODOClean // Build fetch interceptor — routes token requests through Convex for registry servers const fetchFn = createOAuthFetchInterceptor(oauthConfig); @@ -836,16 +875,22 @@ export async function handleOAuthCallback( "resource:", resource?.toString() ?? "(none)", ); // ##TODOClean - const tokens = await fetchToken(provider, discoveryState.authorizationServerUrl, { - metadata: discoveryState.authorizationServerMetadata, - resource, - authorizationCode, - fetchFn, - }); + const tokens = await fetchToken( + provider, + discoveryState.authorizationServerUrl, + { + metadata: discoveryState.authorizationServerMetadata, + resource, + authorizationCode, + fetchFn, + }, + ); await provider.saveTokens(tokens); // Clean up pending state - console.log("[OAuthDebug] REMOVE mcp-oauth-pending (handleOAuthCallback success)"); // ##TODOClean + console.log( + "[OAuthDebug] REMOVE mcp-oauth-pending (handleOAuthCallback success)", + ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); const serverConfig = createServerConfig(serverUrl, tokens); From 32ebf11d8c6407310fed3f316324decd260f96b0 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Tue, 24 Mar 2026 20:39:28 -0700 Subject: [PATCH 16/21] read publish status --- .../client/src/components/RegistryTab.tsx | 26 +++++++-------- .../components/__tests__/RegistryTab.test.tsx | 32 +++++++++++++++++++ .../client/src/hooks/useRegistryServers.ts | 9 ++++++ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index 7f47a32bf..2ee7d43cb 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -10,6 +10,7 @@ import { MonitorSmartphone, MessageSquareText, ChevronDown, + BadgeCheck, } from "lucide-react"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; @@ -195,6 +196,9 @@ function RegistryServerCard({ }) { const { variants, hasDualType } = consolidated; const first = variants[0]; + const isPublisherVerified = variants.some( + (v) => v.publishStatus === "verified", + ); const isConnecting = variants.some((v) => connectingIds.has(v._id)) || @@ -232,22 +236,16 @@ function RegistryServerCard({ {first.publisher} - {first.publisher === "MCPJam" && ( - - - - + )}
diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index 83936c445..07e24f056 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -227,6 +227,38 @@ describe("RegistryTab", () => { expect(screen.getByText("MCPJam")).toBeInTheDocument(); }); + it("shows verified star when publishStatus is verified", () => { + mockHookReturn = { + registryServers: [createMockServer({ publishStatus: "verified" })], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect(screen.getByLabelText("Verified publisher")).toBeInTheDocument(); + }); + + it("does not show verified star when publishStatus is not verified", () => { + mockHookReturn = { + registryServers: [ + createMockServer({ publishStatus: "unverified" }), + ], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + }; + + render(); + + expect( + screen.queryByLabelText("Verified publisher"), + ).not.toBeInTheDocument(); + }); + it("does not show raw URL by default", () => { mockHookReturn = { registryServers: [createMockServer()], diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index 5a1a910c3..9ca475f81 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -17,6 +17,7 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ description: "Connect to Asana to manage tasks, projects, and team workflows directly from your MCP client.", publisher: "MCPJam", + publishStatus: "verified", category: "Project Management", iconUrl: "https://cdn.simpleicons.org/asana", scope: "global", @@ -39,6 +40,7 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ description: "Interact with Linear issues, projects, and cycles. Create, update, and search issues with natural language.", publisher: "MCPJam", + publishStatus: "verified", category: "Project Management", iconUrl: "https://cdn.simpleicons.org/linear", scope: "global", @@ -61,6 +63,7 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ description: "Access and manage Notion pages, databases, and content. Search, create, and update your workspace.", publisher: "MCPJam", + publishStatus: "verified", category: "Productivity", iconUrl: "https://upload.wikimedia.org/wikipedia/commons/4/45/Notion_app_logo.png", @@ -84,6 +87,7 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ description: "Send messages, search conversations, and manage Slack channels directly through MCP.", publisher: "MCPJam", + publishStatus: "verified", category: "Communication", iconUrl: "https://cdn.worldvectorlogo.com/logos/slack-new-logo.svg", scope: "global", @@ -105,6 +109,7 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ description: "Manage repositories, pull requests, issues, and code reviews. Automate your GitHub workflows.", publisher: "MCPJam", + publishStatus: "verified", category: "Developer Tools", scope: "global", transport: { @@ -125,6 +130,7 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ description: "Create and manage Jira issues, sprints, and boards. Track project progress with natural language.", publisher: "MCPJam", + publishStatus: "verified", category: "Project Management", scope: "global", transport: { @@ -144,6 +150,7 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ description: "Search, read, and organize files in Google Drive. Access documents, spreadsheets, and presentations.", publisher: "MCPJam", + publishStatus: "verified", category: "Productivity", scope: "global", transport: { @@ -163,6 +170,7 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ description: "Query payments, subscriptions, and customer data. Monitor your Stripe business metrics.", publisher: "MCPJam", + publishStatus: "verified", category: "Finance", scope: "global", transport: { transportType: "http", url: "https://mcp.stripe.com/sse" }, @@ -184,6 +192,7 @@ export interface RegistryServer { displayName: string; description?: string; iconUrl?: string; + publishStatus?: "verified" | "unverified"; // Client type: "text" for any MCP client, "app" for rich-UI clients clientType?: "text" | "app"; // Scope & ownership From 7e9baaf34b4e727ef28680d9b4a0781d44f0a251 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Tue, 24 Mar 2026 21:38:18 -0700 Subject: [PATCH 17/21] registry stars --- .../client/src/components/RegistryTab.tsx | 51 ++- .../components/__tests__/RegistryTab.test.tsx | 297 +++++++++----- .../__tests__/useRegistryServers.test.tsx | 45 ++- .../client/src/hooks/useRegistryServers.ts | 372 ++++++++++++------ .../format-registry-star-count.test.ts | 15 + .../lib/apis/__tests__/registry-http.test.ts | 19 + .../client/src/lib/apis/registry-http.ts | 150 +++++++ .../client/src/lib/convex-site-url.ts | 12 + .../src/lib/format-registry-star-count.ts | 8 + .../client/src/lib/oauth/mcp-oauth.ts | 14 +- .../client/src/lib/registry-server-types.ts | 50 +++ 11 files changed, 787 insertions(+), 246 deletions(-) create mode 100644 mcpjam-inspector/client/src/lib/__tests__/format-registry-star-count.test.ts create mode 100644 mcpjam-inspector/client/src/lib/apis/__tests__/registry-http.test.ts create mode 100644 mcpjam-inspector/client/src/lib/apis/registry-http.ts create mode 100644 mcpjam-inspector/client/src/lib/convex-site-url.ts create mode 100644 mcpjam-inspector/client/src/lib/format-registry-star-count.ts create mode 100644 mcpjam-inspector/client/src/lib/registry-server-types.ts diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index 2ee7d43cb..eb647fe14 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect } from "react"; import { Package, KeyRound, @@ -11,6 +11,7 @@ import { MessageSquareText, ChevronDown, BadgeCheck, + Star, } from "lucide-react"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; @@ -26,12 +27,12 @@ import { } from "./ui/dropdown-menu"; import { useRegistryServers, - consolidateServers, getRegistryServerName, type EnrichedRegistryServer, - type ConsolidatedRegistryServer, + type EnrichedRegistryCatalogCard, type RegistryConnectionStatus, } from "@/hooks/useRegistryServers"; +import { formatRegistryStarCount } from "@/lib/format-registry-star-count"; import type { ServerFormData } from "@/shared/types.js"; import type { ServerWithName } from "@/hooks/use-app-state"; import { @@ -64,7 +65,7 @@ export function RegistryTab({ const [pendingQuickConnect, setPendingQuickConnect] = useState(() => readPendingQuickConnect()); - const { registryServers, isLoading, connect, disconnect } = + const { catalogCards, isLoading, connect, disconnect, toggleStar } = useRegistryServers({ workspaceId, isAuthenticated, @@ -93,11 +94,6 @@ export function RegistryTab({ } }, [pendingQuickConnect, servers, onNavigate]); - const consolidatedServers = useMemo( - () => consolidateServers(registryServers), - [registryServers], - ); - const handleConnect = async (server: EnrichedRegistryServer) => { setConnectingIds((prev) => new Set(prev).add(server._id)); const serverName = getRegistryServerName(server); @@ -142,7 +138,7 @@ export function RegistryTab({ return ; } - if (registryServers.length === 0) { + if (catalogCards.length === 0) { return ( - {consolidatedServers.map((consolidated) => ( + {catalogCards.map((card) => ( ))}
@@ -182,19 +179,21 @@ export function RegistryTab({ } function RegistryServerCard({ - consolidated, + card, connectingIds, pendingQuickConnect, onConnect, onDisconnect, + onToggleStar, }: { - consolidated: ConsolidatedRegistryServer; + card: EnrichedRegistryCatalogCard; connectingIds: Set; pendingQuickConnect: PendingQuickConnectState | null; onConnect: (server: EnrichedRegistryServer) => void; onDisconnect: (server: EnrichedRegistryServer) => void; + onToggleStar: (registryCardKey: string) => void | Promise; }) { - const { variants, hasDualType } = consolidated; + const { variants, hasDualType } = card; const first = variants[0]; const isPublisherVerified = variants.some( (v) => v.publishStatus === "verified", @@ -250,7 +249,25 @@ function RegistryServerCard({ {/* Top-right action */} -
+
+ {hasDualType ? ( 1; + const ordered = hasDualType + ? [...variants].sort((a) => (a.clientType === "app" ? -1 : 1)) + : variants; + return { + registryCardKey: key, + catalogSortOrder: 0, + variants: ordered, + starCount: 0, + isStarred: false, + hasDualType, + }; +} + vi.mock("@/hooks/useRegistryServers", async (importOriginal) => { const actual = await importOriginal(); @@ -89,11 +112,12 @@ describe("RegistryTab", () => { mockConnect.mockResolvedValue(undefined); mockDisconnect.mockResolvedValue(undefined); mockHookReturn = { - registryServers: [], + catalogCards: [], categories: [], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; }); @@ -101,11 +125,12 @@ describe("RegistryTab", () => { it("renders registry servers when not authenticated", () => { const server = createMockServer(); mockHookReturn = { - registryServers: [server], + catalogCards: [toCatalogCard([server])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -118,11 +143,12 @@ describe("RegistryTab", () => { it("shows header and description when not authenticated", () => { mockHookReturn = { - registryServers: [createMockServer()], + catalogCards: [toCatalogCard([createMockServer()])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -137,11 +163,12 @@ describe("RegistryTab", () => { describe("loading state", () => { it("shows loading skeleton when data is loading", () => { mockHookReturn = { - registryServers: [], + catalogCards: [], categories: [], isLoading: true, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; const { container } = render(); @@ -162,19 +189,22 @@ describe("RegistryTab", () => { describe("auth badges", () => { it("shows OAuth badge with key icon for OAuth servers", () => { mockHookReturn = { - registryServers: [ - createMockServer({ - transport: { - transportType: "http", - url: "https://mcp.test.com/sse", - useOAuth: true, - }, - }), + catalogCards: [ + toCatalogCard([ + createMockServer({ + transport: { + transportType: "http", + url: "https://mcp.test.com/sse", + useOAuth: true, + }, + }), + ]), ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -184,11 +214,12 @@ describe("RegistryTab", () => { it("shows No auth badge for servers without OAuth", () => { mockHookReturn = { - registryServers: [createMockServer()], + catalogCards: [toCatalogCard([createMockServer()])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -211,11 +242,12 @@ describe("RegistryTab", () => { }, }); mockHookReturn = { - registryServers: [server], + catalogCards: [toCatalogCard([server])], categories: ["Project Management"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -229,11 +261,14 @@ describe("RegistryTab", () => { it("shows verified star when publishStatus is verified", () => { mockHookReturn = { - registryServers: [createMockServer({ publishStatus: "verified" })], + catalogCards: [ + toCatalogCard([createMockServer({ publishStatus: "verified" })]), + ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -243,13 +278,14 @@ describe("RegistryTab", () => { it("does not show verified star when publishStatus is not verified", () => { mockHookReturn = { - registryServers: [ - createMockServer({ publishStatus: "unverified" }), + catalogCards: [ + toCatalogCard([createMockServer({ publishStatus: "unverified" })]), ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -261,11 +297,12 @@ describe("RegistryTab", () => { it("does not show raw URL by default", () => { mockHookReturn = { - registryServers: [createMockServer()], + catalogCards: [toCatalogCard([createMockServer()])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -277,11 +314,12 @@ describe("RegistryTab", () => { it("shows Connect button for not_connected servers", () => { mockHookReturn = { - registryServers: [createMockServer()], + catalogCards: [toCatalogCard([createMockServer()])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -291,11 +329,14 @@ describe("RegistryTab", () => { it("shows Connected badge for connected servers", () => { mockHookReturn = { - registryServers: [createMockServer({ connectionStatus: "connected" })], + catalogCards: [ + toCatalogCard([createMockServer({ connectionStatus: "connected" })]), + ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -305,11 +346,14 @@ describe("RegistryTab", () => { it("shows Added badge for servers added but not live", () => { mockHookReturn = { - registryServers: [createMockServer({ connectionStatus: "added" })], + catalogCards: [ + toCatalogCard([createMockServer({ connectionStatus: "added" })]), + ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -321,14 +365,21 @@ describe("RegistryTab", () => { describe("category filtering", () => { it("does not render category filter pills", () => { mockHookReturn = { - registryServers: [ - createMockServer({ _id: "1", category: "Productivity" }), - createMockServer({ _id: "2", category: "Developer Tools" }), + catalogCards: [ + toCatalogCard( + [createMockServer({ _id: "1", category: "Productivity" })], + "c1", + ), + toCatalogCard( + [createMockServer({ _id: "2", category: "Developer Tools" })], + "c2", + ), ], categories: ["Developer Tools", "Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -356,11 +407,15 @@ describe("RegistryTab", () => { category: "Developer Tools", }); mockHookReturn = { - registryServers: [prodServer, devServer], + catalogCards: [ + toCatalogCard([prodServer], "c1"), + toCatalogCard([devServer], "c2"), + ], categories: ["Developer Tools", "Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -374,11 +429,12 @@ describe("RegistryTab", () => { it("calls connect when Connect button is clicked", async () => { const server = createMockServer(); mockHookReturn = { - registryServers: [server], + catalogCards: [toCatalogCard([server])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -393,11 +449,12 @@ describe("RegistryTab", () => { it("calls disconnect from overflow menu", async () => { const server = createMockServer({ connectionStatus: "connected" }); mockHookReturn = { - registryServers: [server], + catalogCards: [toCatalogCard([server])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -416,11 +473,12 @@ describe("RegistryTab", () => { it("navigates to app-builder when a pending server becomes connected", async () => { const server = createMockServer({ displayName: "Asana" }); mockHookReturn = { - registryServers: [server], + catalogCards: [toCatalogCard([server])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; const onNavigate = vi.fn(); @@ -469,11 +527,12 @@ describe("RegistryTab", () => { const server = createMockServer({ displayName: "Linear" }); mockHookReturn = { - registryServers: [server], + catalogCards: [toCatalogCard([server])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; const onNavigate = vi.fn(); @@ -509,11 +568,12 @@ describe("RegistryTab", () => { clientType: "app" as any, }); mockHookReturn = { - registryServers: [server], + catalogCards: [toCatalogCard([server])], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; const onNavigate = vi.fn(); @@ -571,27 +631,38 @@ describe("RegistryTab", () => { it("renders one card per consolidated server (dual-type = 1 card)", () => { mockHookReturn = { - registryServers: [ - createFullServer({ - _id: "asana-text", - displayName: "Asana", - clientType: "text", - }), - createFullServer({ - _id: "asana-app", - displayName: "Asana", - clientType: "app", - }), - createFullServer({ - _id: "linear-1", - displayName: "Linear", - clientType: "text", - }), + catalogCards: [ + toCatalogCard( + [ + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), + ], + "asana", + ), + toCatalogCard( + [ + createFullServer({ + _id: "linear-1", + displayName: "Linear", + clientType: "text", + }), + ], + "linear", + ), ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -605,22 +676,28 @@ describe("RegistryTab", () => { it("shows both Text and App badges on dual-type card", () => { mockHookReturn = { - registryServers: [ - createFullServer({ - _id: "asana-text", - displayName: "Asana", - clientType: "text", - }), - createFullServer({ - _id: "asana-app", - displayName: "Asana", - clientType: "app", - }), + catalogCards: [ + toCatalogCard( + [ + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), + ], + "asana", + ), ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -631,22 +708,28 @@ describe("RegistryTab", () => { it("shows dropdown trigger for dual-type card", () => { mockHookReturn = { - registryServers: [ - createFullServer({ - _id: "asana-text", - displayName: "Asana", - clientType: "text", - }), - createFullServer({ - _id: "asana-app", - displayName: "Asana", - clientType: "app", - }), + catalogCards: [ + toCatalogCard( + [ + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), + ], + "asana", + ), ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -658,17 +741,23 @@ describe("RegistryTab", () => { it("does not show dropdown trigger for single-type card", () => { mockHookReturn = { - registryServers: [ - createFullServer({ - _id: "linear-1", - displayName: "Linear", - clientType: "text", - }), + catalogCards: [ + toCatalogCard( + [ + createFullServer({ + _id: "linear-1", + displayName: "Linear", + clientType: "text", + }), + ], + "linear", + ), ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -678,22 +767,28 @@ describe("RegistryTab", () => { it("dropdown contains Connect as Text and Connect as App options", async () => { mockHookReturn = { - registryServers: [ - createFullServer({ - _id: "asana-text", - displayName: "Asana", - clientType: "text", - }), - createFullServer({ - _id: "asana-app", - displayName: "Asana", - clientType: "app", - }), + catalogCards: [ + toCatalogCard( + [ + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), + ], + "asana", + ), ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); @@ -707,22 +802,28 @@ describe("RegistryTab", () => { it("stores the suffixed runtime name when connecting a dual-type variant", async () => { mockHookReturn = { - registryServers: [ - createFullServer({ - _id: "asana-text", - displayName: "Asana", - clientType: "text", - }), - createFullServer({ - _id: "asana-app", - displayName: "Asana", - clientType: "app", - }), + catalogCards: [ + toCatalogCard( + [ + createFullServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }), + createFullServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }), + ], + "asana", + ), ], categories: ["Productivity"], isLoading: false, connect: mockConnect, disconnect: mockDisconnect, + toggleStar: mockToggleStar, }; render(); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx index 571a2c5cc..f05fb8a5b 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx +++ b/mcpjam-inspector/client/src/hooks/__tests__/useRegistryServers.test.tsx @@ -1,5 +1,6 @@ -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as registryHttp from "@/lib/apis/registry-http"; import { getRegistryServerName, type RegistryServer, @@ -13,6 +14,21 @@ const { mockUseQuery, mockConnectMutation, mockDisconnectMutation } = mockDisconnectMutation: vi.fn(), })); +vi.mock("@/lib/apis/registry-http", () => ({ + fetchRegistryCatalog: vi.fn(), + starRegistryCard: vi.fn(), + unstarRegistryCard: vi.fn(), + mergeGuestRegistryStars: vi.fn(), +})); + +vi.mock("sonner", () => ({ + toast: { error: vi.fn() }, +})); + +vi.mock("@/lib/config", () => ({ + HOSTED_MODE: false, +})); + vi.mock("convex/react", () => ({ useQuery: (...args: unknown[]) => mockUseQuery(...args), useMutation: (name: string) => { @@ -55,14 +71,20 @@ describe("useRegistryServers", () => { beforeEach(() => { vi.clearAllMocks(); mockUseQuery.mockImplementation((name: string) => { - if (name === "registryServers:listRegistryServers") { - return [createRegistryServer()]; - } if (name === "registryServers:getWorkspaceRegistryConnections") { return []; } return undefined; }); + vi.mocked(registryHttp.fetchRegistryCatalog).mockResolvedValue([ + { + registryCardKey: "card-1", + catalogSortOrder: 0, + variants: [createRegistryServer()], + starCount: 0, + isStarred: false, + }, + ]); }); it("disconnects app variants using the runtime server name", async () => { @@ -83,6 +105,10 @@ describe("useRegistryServers", () => { }), ); + await waitFor(() => { + expect(result.current.catalogCards.length).toBe(1); + }); + await act(async () => { await result.current.disconnect(server); }); @@ -115,6 +141,10 @@ describe("useRegistryServers", () => { }), ); + await waitFor(() => { + expect(result.current.catalogCards.length).toBe(1); + }); + await act(async () => { await expect(result.current.disconnect(server)).resolves.toBeUndefined(); }); @@ -126,9 +156,6 @@ describe("useRegistryServers", () => { const server = createRegistryServer({ clientType: "app" }); mockUseQuery.mockImplementation((name: string) => { - if (name === "registryServers:listRegistryServers") { - return [server]; - } if (name === "registryServers:getWorkspaceRegistryConnections") { return [ { @@ -158,6 +185,10 @@ describe("useRegistryServers", () => { }), ); + await waitFor(() => { + expect(result.current.catalogCards.length).toBe(1); + }); + await act(async () => { await result.current.connect(server); }); diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index 9ca475f81..3cfd33fe5 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -1,6 +1,19 @@ -import { useMemo, useState, useEffect } from "react"; +import { useMemo, useState, useEffect, useCallback, useRef } from "react"; import { useQuery, useMutation } from "convex/react"; import type { ServerFormData } from "@/shared/types.js"; +import type { RegistryServer } from "@/lib/registry-server-types"; +import { + fetchRegistryCatalog, + starRegistryCard, + unstarRegistryCard, + mergeGuestRegistryStars, + type RegistryCatalogCard, +} from "@/lib/apis/registry-http"; +import { WebApiError } from "@/lib/apis/web/base"; +import { HOSTED_MODE } from "@/lib/config"; +import { peekStoredGuestToken, clearGuestSession } from "@/lib/guest-session"; +import { resetTokenCache } from "@/lib/apis/web/context"; +import { toast } from "sonner"; /** * Dev-only mock registry servers for local UI testing. @@ -181,65 +194,9 @@ const MOCK_REGISTRY_SERVERS: RegistryServer[] = [ }, ]; -/** - * Shape of a registry server document from the Convex backend. - * Matches the `registryServers` table schema. - */ -export interface RegistryServer { - _id: string; - // Identity - name: string; // Reverse-DNS: "com.acme.internal-tools" - displayName: string; - description?: string; - iconUrl?: string; - publishStatus?: "verified" | "unverified"; - // Client type: "text" for any MCP client, "app" for rich-UI clients - clientType?: "text" | "app"; - // Scope & ownership - scope: "global" | "organization"; - organizationId?: string; - // Transport config - transport: { - transportType: "stdio" | "http"; - command?: string; - args?: string[]; - env?: Record; - url?: string; - headers?: Record; - useOAuth?: boolean; - oauthScopes?: string[]; - oauthCredentialKey?: string; - clientId?: string; - timeout?: number; - }; - // Curation - category?: string; - tags?: string[]; - version?: string; - publisher?: string; - repositoryUrl?: string; - sortOrder?: number; - // Governance - status: "approved" | "pending_review" | "deprecated"; - meta?: unknown; - // Tracking - createdBy: string; - createdAt: number; - updatedAt: number; -} - -/** - * Shape of a registry server connection from `registryServerConnections`. - */ -export interface RegistryServerConnection { - _id: string; - registryServerId: string; - workspaceId: string; - serverId: string; // the actual servers row - connectedBy: string; - connectedAt: number; - configOverridden?: boolean; -} +export type { RegistryServer }; +export type { RegistryServerConnection } from "@/lib/registry-server-types"; +import type { RegistryServerConnection } from "@/lib/registry-server-types"; export type RegistryConnectionStatus = | "not_connected" @@ -252,17 +209,28 @@ export interface EnrichedRegistryServer extends RegistryServer { } /** - * Registry servers grouped by displayName, with variants ordered app-first. + * Consolidated registry card from the HTTP catalog API, enriched with workspace connection state. + */ +export interface EnrichedRegistryCatalogCard { + registryCardKey: string; + catalogSortOrder: number; + variants: EnrichedRegistryServer[]; + starCount: number; + isStarred: boolean; + hasDualType: boolean; +} + +/** + * @deprecated Prefer EnrichedRegistryCatalogCard from the catalog HTTP API. */ export interface ConsolidatedRegistryServer { - /** All variants ordered: app before text. */ variants: EnrichedRegistryServer[]; - /** True when both "text" and "app" variants exist. */ hasDualType: boolean; } /** * Groups registry servers by displayName. Variants are ordered app before text. + * Used for dev mock data only; production catalog is consolidated by the backend. */ export function consolidateServers( servers: EnrichedRegistryServer[], @@ -282,7 +250,6 @@ export function consolidateServers( const result: ConsolidatedRegistryServer[] = []; for (const variants of groups.values()) { - // App before text const ordered = [...variants].sort((a) => a.clientType === "app" ? -1 : 1, ); @@ -301,10 +268,78 @@ export function getRegistryServerName(server: RegistryServer): string { return server.displayName; } +function sortRawCatalogCards(cards: RegistryCatalogCard[]): RegistryCatalogCard[] { + return [...cards].sort((a, b) => { + if (a.isStarred !== b.isStarred) return a.isStarred ? -1 : 1; + return a.catalogSortOrder - b.catalogSortOrder; + }); +} + +function sortCatalogCards( + cards: EnrichedRegistryCatalogCard[], +): EnrichedRegistryCatalogCard[] { + return [...cards].sort((a, b) => { + if (a.isStarred !== b.isStarred) return a.isStarred ? -1 : 1; + return a.catalogSortOrder - b.catalogSortOrder; + }); +} + +function enrichCatalogCards( + cards: RegistryCatalogCard[], + connectedRegistryIds: Set, + liveServers?: Record, +): EnrichedRegistryCatalogCard[] { + return cards.map((card) => { + const variants: EnrichedRegistryServer[] = card.variants.map((server) => { + const isAddedToWorkspace = connectedRegistryIds.has(server._id); + const liveServer = liveServers?.[getRegistryServerName(server)]; + let connectionStatus: RegistryConnectionStatus = "not_connected"; + + if (liveServer?.connectionStatus === "connected") { + connectionStatus = "connected"; + } else if (liveServer?.connectionStatus === "connecting") { + connectionStatus = "connecting"; + } else if (isAddedToWorkspace) { + connectionStatus = "added"; + } + + return { ...server, connectionStatus }; + }); + + return { + registryCardKey: card.registryCardKey, + catalogSortOrder: card.catalogSortOrder, + variants, + starCount: card.starCount, + isStarred: card.isStarred, + hasDualType: variants.length > 1, + }; + }); +} + +function buildMockCatalogCards(): RegistryCatalogCard[] { + const enriched: EnrichedRegistryServer[] = MOCK_REGISTRY_SERVERS.map( + (s) => ({ ...s, connectionStatus: "not_connected" as const }), + ); + const consolidated = consolidateServers(enriched); + return consolidated.map((c, i) => ({ + registryCardKey: `mock:${c.variants[0].displayName}:${i}`, + catalogSortOrder: c.variants[0].sortOrder ?? i, + variants: c.variants.map((v) => { + const { connectionStatus: _, ...rest } = v; + return rest; + }), + starCount: 0, + isStarred: false, + })); +} + function isMissingWorkspaceConnectionError(error: unknown): boolean { return ( error instanceof Error && - error.message.includes("Registry server is not connected to this workspace") + error.message.includes( + "Registry server is not connected to this workspace", + ) ); } @@ -322,21 +357,65 @@ export function useRegistryServers({ }: { workspaceId: string | null; isAuthenticated: boolean; - /** Live MCP connection state from the app, keyed by server name */ liveServers?: Record; onConnect: (formData: ServerFormData) => void; onDisconnect?: (serverName: string) => void; }) { - // Fetch all approved registry servers (requires Convex auth identity) - const remoteRegistryServers = useQuery( - "registryServers:listRegistryServers" as any, - !DEV_MOCK_REGISTRY && isAuthenticated ? ({} as any) : "skip", - ) as RegistryServer[] | undefined; - const registryServers = DEV_MOCK_REGISTRY - ? MOCK_REGISTRY_SERVERS - : remoteRegistryServers; - - // Fetch workspace-level connections + const [rawCatalog, setRawCatalog] = useState( + () => (DEV_MOCK_REGISTRY ? buildMockCatalogCards() : null), + ); + + const mergeRanRef = useRef(false); + + useEffect(() => { + if (!isAuthenticated) mergeRanRef.current = false; + }, [isAuthenticated]); + + const loadCatalog = useCallback(async () => { + if (DEV_MOCK_REGISTRY) { + setRawCatalog(buildMockCatalogCards()); + return; + } + try { + const cards = await fetchRegistryCatalog(); + setRawCatalog(cards); + } catch (error) { + const message = + error instanceof WebApiError + ? error.message + : "Failed to load registry catalog"; + toast.error(message); + setRawCatalog([]); + } + }, []); + + useEffect(() => { + if (DEV_MOCK_REGISTRY) return; + void loadCatalog(); + }, [loadCatalog]); + + useEffect(() => { + if (!HOSTED_MODE || !isAuthenticated || DEV_MOCK_REGISTRY) return; + const guestToken = peekStoredGuestToken(); + if (!guestToken || mergeRanRef.current) return; + mergeRanRef.current = true; + void (async () => { + try { + await mergeGuestRegistryStars(guestToken); + clearGuestSession(); + resetTokenCache(); + await loadCatalog(); + } catch (error) { + mergeRanRef.current = false; + const message = + error instanceof WebApiError + ? error.message + : "Could not merge guest stars"; + toast.error(message); + } + })(); + }, [isAuthenticated, loadCatalog]); + const connections = useQuery( "registryServers:getWorkspaceRegistryConnections" as any, !DEV_MOCK_REGISTRY && isAuthenticated && workspaceId @@ -351,48 +430,35 @@ export function useRegistryServers({ "registryServers:disconnectRegistryServer" as any, ); - // Set of registry server IDs that have a persistent connection in this workspace const connectedRegistryIds = useMemo(() => { if (!connections) return new Set(); return new Set(connections.map((c) => c.registryServerId)); }, [connections]); - // Enrich servers with connection status - const enrichedServers = useMemo(() => { - if (!registryServers) return []; - - return registryServers.map((server) => { - const isAddedToWorkspace = connectedRegistryIds.has(server._id); - const liveServer = liveServers?.[getRegistryServerName(server)]; - let connectionStatus: RegistryConnectionStatus = "not_connected"; - - if (liveServer?.connectionStatus === "connected") { - connectionStatus = "connected"; - } else if (liveServer?.connectionStatus === "connecting") { - connectionStatus = "connecting"; - } else if (isAddedToWorkspace) { - connectionStatus = "added"; - } - - return { ...server, connectionStatus }; - }); - }, [registryServers, connectedRegistryIds, liveServers]); + const catalogCards = useMemo(() => { + if (rawCatalog === null) return []; + const enriched = enrichCatalogCards( + rawCatalog, + connectedRegistryIds, + liveServers, + ); + return sortCatalogCards(enriched); + }, [rawCatalog, connectedRegistryIds, liveServers]); - // Extract unique categories const categories = useMemo(() => { const cats = new Set(); - for (const s of enrichedServers) { - if (s.category) cats.add(s.category); + for (const card of catalogCards) { + for (const v of card.variants) { + if (v.category) cats.add(v.category); + } } return Array.from(cats).sort(); - }, [enrichedServers]); + }, [catalogCards]); - // Track registry server IDs that are pending connection (waiting for OAuth / handshake) const [pendingServerIds, setPendingServerIds] = useState>( new Map(), - ); // registryServerId → suffixed server name + ); - // Record the Convex connection only after the server actually connects useEffect(() => { if (!isAuthenticated || !workspaceId || DEV_MOCK_REGISTRY) return; for (const [registryServerId, serverName] of pendingServerIds) { @@ -434,15 +500,92 @@ export function useRegistryServers({ connections === undefined; const isLoading = - !DEV_MOCK_REGISTRY && - (registryServers === undefined || connectionsAreLoading); + !DEV_MOCK_REGISTRY && (rawCatalog === null || connectionsAreLoading); + + const toggleStar = useCallback( + async (registryCardKey: string) => { + if (DEV_MOCK_REGISTRY) return; + + const priorStarState: { + current: { isStarred: boolean; starCount: number } | null; + } = { current: null }; + + setRawCatalog((prev) => { + if (!prev) return prev; + const card = prev.find((c) => c.registryCardKey === registryCardKey); + if (!card) return prev; + priorStarState.current = { + isStarred: card.isStarred, + starCount: card.starCount, + }; + const nextStarred = !card.isStarred; + const nextCount = Math.max( + 0, + card.starCount + (nextStarred ? 1 : -1), + ); + return sortRawCatalogCards( + prev.map((c) => + c.registryCardKey === registryCardKey + ? { + ...c, + isStarred: nextStarred, + starCount: nextCount, + } + : c, + ), + ); + }); + + const snapshot = priorStarState.current; + if (!snapshot) return; + + try { + const result = snapshot.isStarred + ? await unstarRegistryCard(registryCardKey) + : await starRegistryCard(registryCardKey); + setRawCatalog((prev) => { + if (!prev) return prev; + return sortRawCatalogCards( + prev.map((c) => + c.registryCardKey === registryCardKey + ? { + ...c, + isStarred: result.isStarred, + starCount: result.starCount, + } + : c, + ), + ); + }); + } catch (error) { + setRawCatalog((prev) => { + if (!prev) return prev; + return sortRawCatalogCards( + prev.map((c) => + c.registryCardKey === registryCardKey + ? { + ...c, + isStarred: snapshot.isStarred, + starCount: snapshot.starCount, + } + : c, + ), + ); + }); + const message = + error instanceof WebApiError + ? error.message + : "Could not update star"; + toast.error(message); + } + }, + [], + ); async function connect(server: RegistryServer) { const serverName = getRegistryServerName(server); - // Track this server as pending — Convex record will be created when it actually connects setPendingServerIds((prev) => new Map(prev).set(server._id, serverName)); - // Trigger the local MCP connection onConnect({ name: serverName, type: server.transport.transportType, @@ -459,7 +602,6 @@ export function useRegistryServers({ const serverName = getRegistryServerName(server); let disconnectError: unknown; - // 1. Remove the connection from Convex (only when authenticated with a workspace) if (!DEV_MOCK_REGISTRY && isAuthenticated && workspaceId) { try { await disconnectMutation({ @@ -473,7 +615,6 @@ export function useRegistryServers({ } } - // 2. Trigger the local MCP disconnection onDisconnect?.(serverName); if (disconnectError) { @@ -481,11 +622,20 @@ export function useRegistryServers({ } } + /** Flat list of enriched servers for legacy callers / tests */ + const registryServers = useMemo( + () => catalogCards.flatMap((c) => c.variants), + [catalogCards], + ); + return { - registryServers: enrichedServers, + catalogCards, + registryServers, categories, isLoading, connect, disconnect, + toggleStar, + refetchCatalog: loadCatalog, }; } diff --git a/mcpjam-inspector/client/src/lib/__tests__/format-registry-star-count.test.ts b/mcpjam-inspector/client/src/lib/__tests__/format-registry-star-count.test.ts new file mode 100644 index 000000000..21d0519a8 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/__tests__/format-registry-star-count.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from "vitest"; +import { formatRegistryStarCount } from "../format-registry-star-count"; + +describe("formatRegistryStarCount", () => { + it("shows exact integers below 1000", () => { + expect(formatRegistryStarCount(0)).toBe("0"); + expect(formatRegistryStarCount(999)).toBe("999"); + }); + + it("buckets at 1k+ and 2k+", () => { + expect(formatRegistryStarCount(1000)).toBe("1k+"); + expect(formatRegistryStarCount(1999)).toBe("1k+"); + expect(formatRegistryStarCount(2000)).toBe("2k+"); + }); +}); diff --git a/mcpjam-inspector/client/src/lib/apis/__tests__/registry-http.test.ts b/mcpjam-inspector/client/src/lib/apis/__tests__/registry-http.test.ts new file mode 100644 index 000000000..d63f1f0ab --- /dev/null +++ b/mcpjam-inspector/client/src/lib/apis/__tests__/registry-http.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { extractCatalogCards } from "../registry-http"; + +describe("extractCatalogCards", () => { + it("reads cards from cards, catalog, or top-level array", () => { + const row = { + registryCardKey: "k", + catalogSortOrder: 0, + variants: [], + starCount: 0, + isStarred: false, + }; + expect(extractCatalogCards({ cards: [row] })).toEqual([row]); + expect(extractCatalogCards({ catalog: [row] })).toEqual([row]); + expect(extractCatalogCards([row])).toEqual([row]); + expect(extractCatalogCards({})).toEqual([]); + expect(extractCatalogCards(null)).toEqual([]); + }); +}); diff --git a/mcpjam-inspector/client/src/lib/apis/registry-http.ts b/mcpjam-inspector/client/src/lib/apis/registry-http.ts new file mode 100644 index 000000000..b8ac94b14 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/apis/registry-http.ts @@ -0,0 +1,150 @@ +import { authFetch } from "@/lib/session-token"; +import { WebApiError } from "@/lib/apis/web/base"; +import { getConvexSiteUrl } from "@/lib/convex-site-url"; +import type { RegistryServer } from "@/lib/registry-server-types"; + +export interface RegistryCatalogCard { + registryCardKey: string; + catalogSortOrder: number; + variants: RegistryServer[]; + starCount: number; + isStarred: boolean; +} + +export interface RegistryStarMutationResult { + isStarred: boolean; + starCount: number; +} + +function getRegistryHttpBaseUrl(): string { + const site = getConvexSiteUrl(); + if (!site) { + throw new WebApiError( + 0, + "NO_CONVEX_SITE", + "Convex site URL is not configured (VITE_CONVEX_URL or VITE_CONVEX_SITE_URL)", + ); + } + return site.replace(/\/$/, ""); +} + +async function readJsonBody(response: Response): Promise { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function throwFromFailedResponse( + response: Response, + body: unknown, +): never { + const record = body && typeof body === "object" ? (body as Record) : null; + const code = + typeof record?.code === "string" + ? record.code + : typeof record?.error === "string" + ? record.error + : null; + const message = + typeof record?.message === "string" + ? record.message + : typeof record?.error === "string" + ? record.error + : `Request failed (${response.status})`; + throw new WebApiError(response.status, code, message); +} + +export function extractCatalogCards(data: unknown): RegistryCatalogCard[] { + if (!data || typeof data !== "object") return []; + const obj = data as Record; + if (Array.isArray(obj.cards)) return obj.cards as RegistryCatalogCard[]; + if (Array.isArray(obj.catalog)) return obj.catalog as RegistryCatalogCard[]; + if (Array.isArray(data)) return data as RegistryCatalogCard[]; + return []; +} + +export async function fetchRegistryCatalog( + category?: string | null, +): Promise { + const base = getRegistryHttpBaseUrl(); + const response = await authFetch(`${base}/web/registry/catalog`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify( + category === undefined || category === null + ? {} + : { category }, + ), + }); + const body = await readJsonBody(response); + if (!response.ok) { + throwFromFailedResponse(response, body); + } + return extractCatalogCards(body); +} + +export async function starRegistryCard( + registryCardKey: string, +): Promise { + const base = getRegistryHttpBaseUrl(); + const response = await authFetch(`${base}/web/registry/star`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ registryCardKey }), + }); + const body = await readJsonBody(response); + if (!response.ok) { + throwFromFailedResponse(response, body); + } + return normalizeStarResult(body); +} + +export async function unstarRegistryCard( + registryCardKey: string, +): Promise { + const base = getRegistryHttpBaseUrl(); + const response = await authFetch(`${base}/web/registry/unstar`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ registryCardKey }), + }); + const body = await readJsonBody(response); + if (!response.ok) { + throwFromFailedResponse(response, body); + } + return normalizeStarResult(body); +} + +function normalizeStarResult(body: unknown): RegistryStarMutationResult { + if (!body || typeof body !== "object") { + return { isStarred: false, starCount: 0 }; + } + const o = body as Record; + return { + isStarred: Boolean(o.isStarred), + starCount: + typeof o.starCount === "number" && Number.isFinite(o.starCount) + ? Math.max(0, Math.floor(o.starCount)) + : 0, + }; +} + +export async function mergeGuestRegistryStars( + guestToken: string, +): Promise { + const base = getRegistryHttpBaseUrl(); + const response = await authFetch(`${base}/web/registry/merge-guest-stars`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ guestToken }), + }); + const body = await readJsonBody(response); + if (!response.ok) { + throwFromFailedResponse(response, body); + } + return body; +} diff --git a/mcpjam-inspector/client/src/lib/convex-site-url.ts b/mcpjam-inspector/client/src/lib/convex-site-url.ts new file mode 100644 index 000000000..1bd72da2c --- /dev/null +++ b/mcpjam-inspector/client/src/lib/convex-site-url.ts @@ -0,0 +1,12 @@ +/** + * Derive the Convex HTTP actions URL (*.convex.site) from the Convex client URL. + */ +export function getConvexSiteUrl(): string | null { + const siteUrl = import.meta.env.VITE_CONVEX_SITE_URL as string | undefined; + if (siteUrl) return siteUrl; + const cloudUrl = import.meta.env.VITE_CONVEX_URL as string | undefined; + if (cloudUrl && typeof cloudUrl === "string") { + return cloudUrl.replace(".convex.cloud", ".convex.site"); + } + return null; +} diff --git a/mcpjam-inspector/client/src/lib/format-registry-star-count.ts b/mcpjam-inspector/client/src/lib/format-registry-star-count.ts new file mode 100644 index 000000000..343a35105 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/format-registry-star-count.ts @@ -0,0 +1,8 @@ +/** + * Format registry star counts for display: exact below 1000, then Nk+ buckets. + */ +export function formatRegistryStarCount(count: number): string { + if (!Number.isFinite(count) || count < 0) return "0"; + if (count < 1000) return String(Math.floor(count)); + return `${Math.floor(count / 1000)}k+`; +} diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index 1338a9e4d..93e69bfcc 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -19,23 +19,11 @@ import { authFetch } from "@/lib/session-token"; import { HOSTED_MODE } from "@/lib/config"; import { captureServerDetailModalOAuthResume } from "@/lib/server-detail-modal-resume"; import { getRedirectUri } from "./constants"; +import { getConvexSiteUrl } from "@/lib/convex-site-url"; // Store original fetch for restoration const originalFetch = window.fetch; -/** - * Derive the Convex HTTP actions URL (*.convex.site) from the Convex client URL. - */ -function getConvexSiteUrl(): string | null { - const siteUrl = (import.meta as any).env?.VITE_CONVEX_SITE_URL; - if (siteUrl) return siteUrl; - const cloudUrl = (import.meta as any).env?.VITE_CONVEX_URL; - if (cloudUrl && typeof cloudUrl === "string") { - return cloudUrl.replace(".convex.cloud", ".convex.site"); - } - return null; -} - interface StoredOAuthDiscoveryState { serverUrl: string; discoveryState: OAuthDiscoveryState; diff --git a/mcpjam-inspector/client/src/lib/registry-server-types.ts b/mcpjam-inspector/client/src/lib/registry-server-types.ts new file mode 100644 index 000000000..c3a9df034 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/registry-server-types.ts @@ -0,0 +1,50 @@ +/** + * Registry server documents and workspace connections (Convex / catalog variants). + */ + +export interface RegistryServer { + _id: string; + // Identity + name: string; + displayName: string; + description?: string; + iconUrl?: string; + publishStatus?: "verified" | "unverified"; + clientType?: "text" | "app"; + scope: "global" | "organization"; + organizationId?: string; + transport: { + transportType: "stdio" | "http"; + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Record; + useOAuth?: boolean; + oauthScopes?: string[]; + oauthCredentialKey?: string; + clientId?: string; + timeout?: number; + }; + category?: string; + tags?: string[]; + version?: string; + publisher?: string; + repositoryUrl?: string; + sortOrder?: number; + status: "approved" | "pending_review" | "deprecated"; + meta?: unknown; + createdBy: string; + createdAt: number; + updatedAt: number; +} + +export interface RegistryServerConnection { + _id: string; + registryServerId: string; + workspaceId: string; + serverId: string; + connectedBy: string; + connectedAt: number; + configOverridden?: boolean; +} From c9dfb336940f4ae0abc795241c004c9b1d1b7cc9 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Wed, 25 Mar 2026 00:14:11 -0700 Subject: [PATCH 18/21] so much registry --- .../client/src/components/RegistryTab.tsx | 61 +- .../client/src/components/ServersTab.tsx | 623 ++++++++++++++---- .../components/__tests__/RegistryTab.test.tsx | 138 +++- .../components/__tests__/ServersTab.test.tsx | 495 ++++++++++++-- .../__tests__/consolidateServers.test.ts | 25 + .../client/src/hooks/useRegistryServers.ts | 19 +- .../quick-connect-catalog-sort.test.ts | 77 +++ .../src/lib/quick-connect-catalog-sort.ts | 38 ++ 8 files changed, 1238 insertions(+), 238 deletions(-) create mode 100644 mcpjam-inspector/client/src/lib/__tests__/quick-connect-catalog-sort.test.ts create mode 100644 mcpjam-inspector/client/src/lib/quick-connect-catalog-sort.ts diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index eb647fe14..12ee8ccea 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -42,6 +42,9 @@ import { type PendingQuickConnectState, } from "@/lib/quick-connect-pending"; +/** Drop stale registry pending when OAuth was abandoned (browser closed) without a terminal callback. */ +const REGISTRY_PENDING_OAUTH_STALE_MS = 45 * 60 * 1000; + interface RegistryTabProps { workspaceId: string | null; isAuthenticated: boolean; @@ -77,7 +80,6 @@ export function RegistryTab({ // Auto-redirect to App Builder when a pending server becomes connected. // We persist in localStorage to survive OAuth redirects (page remounts). useEffect(() => { - if (!onNavigate) return; const pending = pendingQuickConnect; if (!pending || pending.sourceTab !== "registry") return; const liveServer = @@ -90,10 +92,45 @@ export function RegistryTab({ if (liveServer?.connectionStatus === "connected") { clearPendingQuickConnect(); setPendingQuickConnect(null); - onNavigate("app-builder"); + onNavigate?.("app-builder"); } }, [pendingQuickConnect, servers, onNavigate]); + // `useRegistryServers.connect` returns after dispatching OAuth; pending stays for redirect UX. + // Mirror ServersTab: clear when auth fails or the server is disconnected so the card does not + // show "Connecting" forever (localStorage would otherwise keep matching pending). + useEffect(() => { + if (pendingQuickConnect?.sourceTab !== "registry") return; + + const pending = pendingQuickConnect; + const pendingServer = servers?.[pending.serverName]; + const age = Date.now() - pending.createdAt; + + if (pendingServer) { + if ( + pendingServer.connectionStatus === "failed" || + pendingServer.connectionStatus === "disconnected" + ) { + clearPendingQuickConnect(); + setPendingQuickConnect(null); + return; + } + if ( + pendingServer.connectionStatus === "oauth-flow" && + age > REGISTRY_PENDING_OAUTH_STALE_MS + ) { + clearPendingQuickConnect(); + setPendingQuickConnect(null); + } + return; + } + + if (age > 48 * 60 * 60 * 1000) { + clearPendingQuickConnect(); + setPendingQuickConnect(null); + } + }, [pendingQuickConnect, servers]); + const handleConnect = async (server: EnrichedRegistryServer) => { setConnectingIds((prev) => new Set(prev).add(server._id)); const serverName = getRegistryServerName(server); @@ -331,8 +368,6 @@ function DualTypeAction({ const activeVariant = connectedVariant ?? addedVariant; if (activeVariant) { - const label = - activeVariant.connectionStatus === "connected" ? "Connected" : "Added"; const disconnectLabel = activeVariant.connectionStatus === "connected" ? "Disconnect" : "Remove"; @@ -348,16 +383,16 @@ function DualTypeAction({ tabIndex={-1} > - {label} + Connected ) : ( )} @@ -499,12 +534,12 @@ function TopRightAction({ return (
diff --git a/mcpjam-inspector/client/src/components/ServersTab.tsx b/mcpjam-inspector/client/src/components/ServersTab.tsx index 2ae06ff1f..e2047c47c 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -1,7 +1,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Card } from "./ui/card"; import { Button } from "./ui/button"; -import { Plus, FileText, Package, ArrowRight, Loader2 } from "lucide-react"; +import { + Plus, + FileText, + Package, + ArrowRight, + Loader2, + BadgeCheck, + Star, + ChevronDown, + ChevronRight, + MonitorSmartphone, + MessageSquareText, +} from "lucide-react"; import { ServerWithName, type ServerUpdateResult } from "@/hooks/use-app-state"; import { ServerConnectionCard } from "./connection/ServerConnectionCard"; import { AddServerModal } from "./connection/AddServerModal"; @@ -14,8 +26,19 @@ import { JsonImportModal } from "./connection/JsonImportModal"; import { ServerFormData } from "@/shared/types.js"; import { MCPIcon } from "./ui/mcp-icon"; import { usePostHog } from "posthog-js/react"; -import { useQuery } from "convex/react"; -import type { RegistryServer } from "@/hooks/useRegistryServers"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { + useRegistryServers, + getRegistryServerName, + type EnrichedRegistryCatalogCard, + type EnrichedRegistryServer, +} from "@/hooks/useRegistryServers"; +import { formatRegistryStarCount } from "@/lib/format-registry-star-count"; import { detectEnvironment, detectPlatform } from "@/lib/PosthogUtils"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card"; import { @@ -63,9 +86,53 @@ import { readServerDetailModalOAuthResume, writeOpenServerDetailModalState, } from "@/lib/server-detail-modal-resume"; +import { cn } from "@/lib/utils"; +import { compareQuickConnectCatalogCards } from "@/lib/quick-connect-catalog-sort"; const ORDER_STORAGE_KEY = "mcp-server-order"; +function variantIsAlreadyInWorkspaceForQuickConnect( + v: EnrichedRegistryServer, + workspaceServers: Record, + pendingQuickConnect: PendingQuickConnectState | null, + isPendingQuickConnectVisible: boolean, +): boolean { + const name = getRegistryServerName(v); + const ws = workspaceServers[name]; + if (!ws) return false; + + const isThisPendingQuickConnect = + isPendingQuickConnectVisible && + pendingQuickConnect?.sourceTab === "servers" && + (v._id === pendingQuickConnect.registryServerId || + name === pendingQuickConnect.serverName) && + (ws.connectionStatus === "oauth-flow" || + ws.connectionStatus === "connecting"); + + if (isThisPendingQuickConnect) { + return false; + } + + return true; +} + +/** True if this catalog card should not appear in Quick Connect (already in workspace). */ +function isQuickConnectCardExcludedByWorkspace( + card: EnrichedRegistryCatalogCard, + workspaceServers: Record, + pendingQuickConnect: PendingQuickConnectState | null, + isPendingQuickConnectVisible: boolean, +): boolean { + return card.variants.some((v) => + variantIsAlreadyInWorkspaceForQuickConnect( + v, + workspaceServers, + pendingQuickConnect, + isPendingQuickConnectVisible, + ), + ); +} + function loadServerOrder(workspaceId: string): string[] | undefined { try { const raw = localStorage.getItem(ORDER_STORAGE_KEY); @@ -86,6 +153,153 @@ function saveServerOrder(workspaceId: string, orderedNames: string[]): void { } } +function ServersQuickConnectMiniCard({ + card, + pendingQuickConnect, + pendingPhaseLabel, + onConnect, +}: { + card: EnrichedRegistryCatalogCard; + pendingQuickConnect: PendingQuickConnectState | null; + pendingPhaseLabel: string; + onConnect: (server: EnrichedRegistryServer) => void | Promise; +}) { + const first = card.variants[0]; + const isPublisherVerified = card.variants.some( + (v) => v.publishStatus === "verified", + ); + const isPending = + pendingQuickConnect?.sourceTab === "servers" && + card.variants.some( + (v) => + v._id === pendingQuickConnect.registryServerId || + getRegistryServerName(v) === pendingQuickConnect.serverName, + ); + + const description = first.description?.trim() ?? ""; + const descLine = + description.length > 140 ? `${description.slice(0, 137)}…` : description; + + const connectControl = + card.hasDualType ? ( + + + + + + {card.variants.map((v) => ( + void onConnect(v)} + > + {v.clientType === "app" ? ( + + ) : ( + + )} + Connect as {v.clientType === "app" ? "App" : "Text"} + + ))} + + + ) : ( + + ); + + return ( +
+
+ {first.iconUrl ? ( + + ) : ( +
+ +
+ )} +
+
+
+

+ {first.displayName} +

+
+ + {first.publisher ?? "—"} + + {isPublisherVerified ? ( + + + + ) : null} + + + {formatRegistryStarCount(card.starCount)} + +
+
+
{connectControl}
+
+
+
+

+ {descLine || "—"} +

+
+ ); +} + function SortableServerCard({ id, dndDisabled, @@ -181,19 +395,23 @@ export function ServersTab({ const { isAuthenticated } = useConvexAuth(); const [pendingQuickConnect, setPendingQuickConnect] = useState(() => readPendingQuickConnect()); + const registryWorkspaceId = + workspaces[activeWorkspaceId]?.sharedWorkspaceId ?? null; + + const { + catalogCards, + isLoading: isRegistryCatalogLoading, + connect: connectRegistryServer, + } = useRegistryServers({ + workspaceId: registryWorkspaceId, + isAuthenticated, + liveServers: workspaceServers, + onConnect, + }); + + const [quickConnectMiniCardsExpanded, setQuickConnectMiniCardsExpanded] = + useState(() => Object.keys(workspaceServers).length <= 2); - // Fetch featured registry servers for the quick-connect section - const registryServers = useQuery( - "registryServers:listRegistryServers" as any, - isAuthenticated ? ({} as any) : "skip", - ) as RegistryServer[] | undefined; - const featuredRegistryServers = useMemo(() => { - if (!registryServers) return []; - const featured = registryServers - .filter((s) => s.sortOrder != null) - .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); - return (featured.length > 0 ? featured : registryServers).slice(0, 4); - }, [registryServers]); const { isVisible: isJsonRpcPanelVisible, toggle: toggleJsonRpcPanel } = useJsonRpcPanelVisibility(); const [isAddingServer, setIsAddingServer] = useState(false); @@ -363,9 +581,6 @@ export function ServersTab({ }, [pendingQuickConnect, workspaceServers]); const connectedCount = Object.keys(workspaceServers).length; - const hasConnectedServers = Object.values(workspaceServers).some( - (server) => server.connectionStatus === "connected", - ); const hasAnyServers = connectedCount > 0; const pendingQuickConnectServer = pendingQuickConnect?.sourceTab === "servers" @@ -380,6 +595,50 @@ export function ServersTab({ pendingQuickConnectServer?.connectionStatus === "connecting" ? "Finishing setup..." : "Authorizing..."; + + const featuredQuickConnectCards = useMemo(() => { + return [...catalogCards] + .sort(compareQuickConnectCatalogCards) + .filter( + (card) => + !isQuickConnectCardExcludedByWorkspace( + card, + workspaceServers, + pendingQuickConnect, + isPendingQuickConnectVisible, + ), + ) + .slice(0, 4); + }, [ + catalogCards, + workspaceServers, + pendingQuickConnect, + isPendingQuickConnectVisible, + ]); + + const quickConnectCatalogAvailableCount = featuredQuickConnectCards.length; + + const totalServerCards = connectedCount; + /** Compact header + collapsible mini-cards when many servers on the tab; full module when ≤2 or pending OAuth. */ + const isQuickConnectMinimized = + totalServerCards > 2 && !isPendingQuickConnectVisible; + + useEffect(() => { + if (totalServerCards > 2) { + setQuickConnectMiniCardsExpanded(false); + } else { + setQuickConnectMiniCardsExpanded(true); + } + }, [totalServerCards]); + + const shouldShowQuickConnect = + isRegistryCatalogLoading || + quickConnectCatalogAvailableCount > 0 || + isPendingQuickConnectVisible; + + const shouldShowBrowseRegistryOnly = + !shouldShowQuickConnect && quickConnectCatalogAvailableCount > 0; + const activeWorkspace = workspaces[activeWorkspaceId]; const sharedWorkspaceId = activeWorkspace?.sharedWorkspaceId; const { serversRecord: sharedWorkspaceServersRecord } = @@ -472,27 +731,6 @@ export function ServersTab({ }); }; - const handleQuickConnect = (server: RegistryServer) => { - const nextPendingQuickConnect: PendingQuickConnectState = { - serverName: server.displayName, - registryServerId: server._id, - displayName: server.displayName, - sourceTab: "servers", - createdAt: Date.now(), - }; - writePendingQuickConnect(nextPendingQuickConnect); - setPendingQuickConnect(nextPendingQuickConnect); - onConnect({ - name: server.displayName, - type: server.transport.transportType, - url: server.transport.url, - useOAuth: server.transport.useOAuth, - oauthScopes: server.transport.oauthScopes, - oauthCredentialKey: server.transport.oauthCredentialKey, - registryServerId: server._id, - }); - }; - const clearPendingQuickConnectIfMatches = useCallback( (serverName: string) => { if (pendingQuickConnect?.serverName !== serverName) { @@ -504,6 +742,25 @@ export function ServersTab({ [pendingQuickConnect], ); + const handleQuickConnect = async (server: EnrichedRegistryServer) => { + const serverName = getRegistryServerName(server); + const nextPendingQuickConnect: PendingQuickConnectState = { + serverName, + registryServerId: server._id, + displayName: server.displayName, + sourceTab: "servers", + createdAt: Date.now(), + }; + writePendingQuickConnect(nextPendingQuickConnect); + setPendingQuickConnect(nextPendingQuickConnect); + try { + await connectRegistryServer(server); + } catch { + clearPendingQuickConnect(); + setPendingQuickConnect(null); + } + }; + const handleAddServerClick = () => { posthog.capture("add_server_button_clicked", { location: "servers_tab", @@ -566,6 +823,142 @@ export function ServersTab({ ); + const renderQuickConnectSection = () => { + if (!shouldShowQuickConnect) return null; + + const minimized = isQuickConnectMinimized; + const hasMiniCardContent = + isRegistryCatalogLoading || featuredQuickConnectCards.length > 0; + const showMiniCardsRow = + hasMiniCardContent && + (!minimized || quickConnectMiniCardsExpanded); + const featuredCount = featuredQuickConnectCards.length; + const featuredCountForLabel = + isRegistryCatalogLoading && featuredCount === 0 + ? null + : featuredCount; + + return ( +
+
+ {minimized ? ( +
+ + Quick Connect + + {hasMiniCardContent ? ( + + ) : null} +
+ ) : ( +
+

+ Quick Connect +

+
+ )} + {onNavigateToRegistry ? ( + + ) : null} +
+ {isPendingQuickConnectVisible && pendingQuickConnect && ( + +
+ +
+

+ {`Connecting ${pendingQuickConnect.displayName}...`} +

+

+ {pendingQuickConnectPhaseLabel} +

+
+
+
+ )} + {showMiniCardsRow ? ( +
+ {isRegistryCatalogLoading && featuredQuickConnectCards.length === 0 + ? Array.from({ length: 4 }).map((_, i) => ( + + )) + : featuredQuickConnectCards.map((card) => ( + + ))} +
+ ) : null} +
+ ); + }; + const renderConnectedContent = () => ( {/* Main Server List Panel */} @@ -577,10 +970,24 @@ export function ServersTab({ {/* Header Section */}
+ {shouldShowBrowseRegistryOnly && onNavigateToRegistry ? ( + + ) : null} {renderServerActionsMenu()}
+ {renderQuickConnectSection()} + {/* Server Cards Grid (drag-and-drop reorderable, order saved to localStorage only) */} { + clearPendingQuickConnectIfMatches(serverName); + onDisconnect(serverName); + }} onReconnect={onReconnect} - onRemove={onRemove} + onRemove={(serverName) => { + clearPendingQuickConnectIfMatches(serverName); + onRemove(serverName); + }} hostedServerId={sharedWorkspaceServersRecord[name]?._id} onOpenDetailModal={handleOpenDetailModal} /> @@ -622,9 +1035,15 @@ export function ServersTab({ needsReconnect={ reconnectWarningByServerName[activeServer.name] } - onDisconnect={onDisconnect} + onDisconnect={(serverName) => { + clearPendingQuickConnectIfMatches(serverName); + onDisconnect(serverName); + }} onReconnect={onReconnect} - onRemove={onRemove} + onRemove={(serverName) => { + clearPendingQuickConnectIfMatches(serverName); + onRemove(serverName); + }} hostedServerId={ sharedWorkspaceServersRecord[activeId!]?._id } @@ -662,112 +1081,25 @@ export function ServersTab({ const renderEmptyContent = () => (
{/* Header Section */} -
+
+ {shouldShowBrowseRegistryOnly && onNavigateToRegistry ? ( + + ) : null} {renderServerActionsMenu()}
- {/* Quick Connect from Registry */} - {isAuthenticated && featuredRegistryServers.length > 0 && ( -
-
-

- Quick Connect -

- {onNavigateToRegistry && ( - - )} -
- {isPendingQuickConnectVisible && pendingQuickConnect && ( - -
- -
-

- {`Connecting ${pendingQuickConnect.displayName}...`} -

-

- {pendingQuickConnectPhaseLabel} -

-
-
-
- )} -
- {featuredRegistryServers.map((server) => { - const isPendingServer = - pendingQuickConnect?.sourceTab === "servers" && - (pendingQuickConnect.registryServerId === server._id || - pendingQuickConnect.serverName === server.displayName); - - return ( - - ); - })} -
- {isPendingQuickConnectVisible && pendingQuickConnectServer && ( - { - clearPendingQuickConnectIfMatches(serverName); - onDisconnect(serverName); - }} - onReconnect={onReconnect} - onRemove={(serverName) => { - clearPendingQuickConnectIfMatches(serverName); - onRemove(serverName); - }} - hostedServerId={ - sharedWorkspaceServersRecord[pendingQuickConnectServer.name] - ?._id - } - onOpenDetailModal={handleOpenDetailModal} - /> - )} -
- )} + {renderQuickConnectSection()} {/* Empty State */} @@ -802,8 +1134,7 @@ export function ServersTab({
{isLoadingWorkspaces ? renderLoadingContent() - : hasConnectedServers || - (hasAnyServers && !isPendingQuickConnectVisible) + : hasAnyServers ? renderConnectedContent() : renderEmptyContent()} diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx index da2822da8..73c065c9d 100644 --- a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -1,11 +1,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { RegistryTab } from "../RegistryTab"; -import type { - EnrichedRegistryServer, - EnrichedRegistryCatalogCard, +import { + sortRegistryVariantsAppBeforeText, + type EnrichedRegistryServer, + type EnrichedRegistryCatalogCard, } from "@/hooks/useRegistryServers"; -import { readPendingQuickConnect } from "@/lib/quick-connect-pending"; +import { + readPendingQuickConnect, + writePendingQuickConnect, +} from "@/lib/quick-connect-pending"; // Mock the useRegistryServers hook const mockConnect = vi.fn(); @@ -26,7 +30,7 @@ function toCatalogCard( ): EnrichedRegistryCatalogCard { const hasDualType = variants.length > 1; const ordered = hasDualType - ? [...variants].sort((a) => (a.clientType === "app" ? -1 : 1)) + ? sortRegistryVariantsAppBeforeText(variants) : variants; return { registryCardKey: key, @@ -344,7 +348,7 @@ describe("RegistryTab", () => { expect(screen.getByText("Connected")).toBeInTheDocument(); }); - it("shows Added badge for servers added but not live", () => { + it("shows Connect for servers in workspace but not live", () => { mockHookReturn = { catalogCards: [ toCatalogCard([createMockServer({ connectionStatus: "added" })]), @@ -358,7 +362,12 @@ describe("RegistryTab", () => { render(); - expect(screen.getByText("Added")).toBeInTheDocument(); + const connectBtn = screen.getByRole("button", { name: "Connect" }); + expect(connectBtn).toBeInTheDocument(); + expect(connectBtn).toHaveAttribute( + "title", + "Server is in your workspace — click to connect", + ); }); }); @@ -446,6 +455,26 @@ describe("RegistryTab", () => { }); }); + it("calls connect when Connect is clicked for added-but-not-live server", async () => { + const server = createMockServer({ connectionStatus: "added" }); + mockHookReturn = { + catalogCards: [toCatalogCard([server])], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + toggleStar: mockToggleStar, + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Connect" })); + + await waitFor(() => { + expect(mockConnect).toHaveBeenCalledWith(server); + }); + }); + it("calls disconnect from overflow menu", async () => { const server = createMockServer({ connectionStatus: "connected" }); mockHookReturn = { @@ -469,6 +498,96 @@ describe("RegistryTab", () => { }); }); + describe("pending quick connect cleanup", () => { + it("clears registry pending when server auth fails so the card leaves Connecting", async () => { + const server = createMockServer({ + displayName: "PostHog", + clientType: "text", + _id: "ph-1", + }); + const serverName = "PostHog (Text)"; + writePendingQuickConnect({ + serverName, + registryServerId: "ph-1", + displayName: "PostHog", + sourceTab: "registry", + createdAt: Date.now(), + }); + mockHookReturn = { + catalogCards: [toCatalogCard([server], "posthog")], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + toggleStar: mockToggleStar, + }; + + render( + , + ); + + await waitFor(() => { + expect(readPendingQuickConnect()).toBeNull(); + }); + expect(screen.queryByText("Connecting")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument(); + }); + + it("clears registry pending when oauth-flow exceeds the stale window", async () => { + const server = createMockServer({ + displayName: "PostHog", + clientType: "text", + _id: "ph-1", + }); + const serverName = "PostHog (Text)"; + writePendingQuickConnect({ + serverName, + registryServerId: "ph-1", + displayName: "PostHog", + sourceTab: "registry", + createdAt: Date.now() - 46 * 60 * 1000, + }); + mockHookReturn = { + catalogCards: [toCatalogCard([server], "posthog")], + categories: ["Productivity"], + isLoading: false, + connect: mockConnect, + disconnect: mockDisconnect, + toggleStar: mockToggleStar, + }; + + render( + , + ); + + await waitFor(() => { + expect(readPendingQuickConnect()).toBeNull(); + }); + }); + }); + describe("auto-redirect to App Builder", () => { it("navigates to app-builder when a pending server becomes connected", async () => { const server = createMockServer({ displayName: "Asana" }); @@ -798,6 +917,11 @@ describe("RegistryTab", () => { const itemTexts = items.map((el) => el.textContent); expect(itemTexts.some((t) => t?.includes("Text"))).toBe(true); expect(itemTexts.some((t) => t?.includes("App"))).toBe(true); + const appIdx = itemTexts.findIndex((t) => t?.includes("App")); + const textIdx = itemTexts.findIndex((t) => t?.includes("Text")); + expect(appIdx).toBeGreaterThanOrEqual(0); + expect(textIdx).toBeGreaterThanOrEqual(0); + expect(appIdx).toBeLessThan(textIdx); }); it("stores the suffixed runtime name when connecting a dual-type variant", async () => { diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index 2b5c368b2..cf5f90b07 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -11,22 +11,119 @@ import { writeOpenServerDetailModalState, } from "@/lib/server-detail-modal-resume"; import { writePendingQuickConnect } from "@/lib/quick-connect-pending"; +import type { EnrichedRegistryCatalogCard } from "@/hooks/useRegistryServers"; +import { getRegistryServerName } from "@/hooks/useRegistryServers"; + +function createLinearCatalogCard(): EnrichedRegistryCatalogCard { + const server = { + _id: "linear-1", + name: "app.linear.mcp", + displayName: "Linear", + description: "Interact with Linear issues.", + publisher: "MCPJam", + publishStatus: "verified" as const, + scope: "global" as const, + transport: { + transportType: "http" as const, + url: "https://mcp.linear.app/mcp", + useOAuth: true, + oauthScopes: ["read", "write"], + }, + status: "approved" as const, + createdBy: "u", + createdAt: 0, + updatedAt: 0, + connectionStatus: "not_connected" as const, + }; + return { + registryCardKey: "card-linear", + catalogSortOrder: 1, + variants: [server], + starCount: 42, + isStarred: false, + hasDualType: false, + }; +} + +function createNotionCatalogCard(): EnrichedRegistryCatalogCard { + const server = { + _id: "notion-1", + name: "com.notion.mcp", + displayName: "Notion", + description: "Access Notion pages.", + publisher: "MCPJam", + publishStatus: "verified" as const, + scope: "global" as const, + transport: { + transportType: "http" as const, + url: "https://mcp.notion.com/mcp", + useOAuth: true, + }, + status: "approved" as const, + createdBy: "u", + createdAt: 0, + updatedAt: 0, + connectionStatus: "not_connected" as const, + }; + return { + registryCardKey: "card-notion", + catalogSortOrder: 2, + variants: [server], + starCount: 5, + isStarred: false, + hasDualType: false, + }; +} + +function createDualTypeCatalogCard(): EnrichedRegistryCatalogCard { + const shared = { + name: "app.dual.mcp", + displayName: "DualServer", + description: "Dual-type MCP.", + publisher: "Acme", + publishStatus: "verified" as const, + scope: "global" as const, + status: "approved" as const, + createdBy: "u", + createdAt: 0, + updatedAt: 0, + connectionStatus: "not_connected" as const, + }; + const app = { + ...shared, + _id: "dual-app", + clientType: "app" as const, + transport: { + transportType: "http" as const, + url: "https://example.com/app", + useOAuth: true, + }, + }; + const text = { + ...shared, + _id: "dual-text", + clientType: "text" as const, + transport: { + transportType: "http" as const, + url: "https://example.com/text", + useOAuth: true, + }, + }; + return { + registryCardKey: "card-dual", + catalogSortOrder: 1, + variants: [app, text], + starCount: 10, + isStarred: false, + hasDualType: true, + }; +} let mockIsAuthenticated = false; -let mockRegistryServers: - | Array<{ - _id: string; - displayName: string; - publisher?: string; - transport: { - transportType: "http" | "stdio"; - url?: string; - useOAuth?: boolean; - oauthScopes?: string[]; - oauthCredentialKey?: string; - }; - }> - | undefined; +let mockCatalogCards: EnrichedRegistryCatalogCard[] = []; +let mockRegistryLoading = false; +const mockConnectRegistry = vi.fn(); +const mockUseRegistryServers = vi.fn(); vi.mock("posthog-js/react", () => ({ usePostHog: () => ({ @@ -38,9 +135,26 @@ vi.mock("convex/react", () => ({ useConvexAuth: () => ({ isAuthenticated: mockIsAuthenticated, }), - useQuery: () => mockRegistryServers, + useQuery: () => undefined, + useMutation: () => vi.fn(), })); +vi.mock("@/hooks/useRegistryServers", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useRegistryServers: (args: unknown) => { + mockUseRegistryServers(args); + return { + catalogCards: mockCatalogCards, + isLoading: mockRegistryLoading, + connect: mockConnectRegistry, + }; + }, + }; +}); + vi.mock("@workos-inc/authkit-react", () => ({ useAuth: () => ({ user: null, @@ -270,13 +384,27 @@ describe("ServersTab shared detail modal", () => { isLoadingWorkspaces: false, onWorkspaceShared: vi.fn(), onLeaveWorkspace: vi.fn(), + onNavigateToRegistry: vi.fn(), }; beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); mockIsAuthenticated = false; - mockRegistryServers = undefined; + mockCatalogCards = []; + mockRegistryLoading = false; + mockConnectRegistry.mockReset(); + mockConnectRegistry.mockImplementation(async (server) => { + defaultProps.onConnect({ + name: getRegistryServerName(server), + type: server.transport.transportType, + url: server.transport.url, + useOAuth: server.transport.useOAuth, + oauthScopes: server.transport.oauthScopes, + oauthCredentialKey: server.transport.oauthCredentialKey, + registryServerId: server._id, + }); + }); }); it("opens the shared modal from a server card on configuration", () => { @@ -520,25 +648,26 @@ describe("ServersTab shared detail modal", () => { expect(screen.queryByText("Needs reconnect")).not.toBeInTheDocument(); }); + it("renders Quick Connect module helper copy and Browse Registry in the section header", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createLinearCatalogCard()]; + + render(); + + expect(screen.getByText("Quick Connect")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-browse-registry"), + ).toBeInTheDocument(); + expect(screen.getByTestId("servers-quick-connect-mini-card")).toBeInTheDocument(); + }); + it("keeps quick connect visible after clicking a quick connect server", () => { mockIsAuthenticated = true; - mockRegistryServers = [ - { - _id: "linear-1", - displayName: "Linear", - publisher: "MCPJam", - transport: { - transportType: "http", - url: "https://mcp.linear.app/mcp", - useOAuth: true, - oauthScopes: ["read", "write"], - }, - }, - ]; + mockCatalogCards = [createLinearCatalogCard()]; render(); - fireEvent.click(screen.getByLabelText("Connect Linear")); + fireEvent.click(screen.getByRole("button", { name: "Connect Linear" })); expect(defaultProps.onConnect).toHaveBeenCalledWith( expect.objectContaining({ @@ -554,23 +683,12 @@ describe("ServersTab shared detail modal", () => { expect(screen.getAllByText("Authorizing...").length).toBeGreaterThanOrEqual( 1, ); - expect(screen.getByLabelText("Connect Linear")).toBeDisabled(); + expect(screen.getByRole("button", { name: "Connect Linear" })).toBeDisabled(); }); it("keeps quick connect visible during oauth-flow after return", () => { mockIsAuthenticated = true; - mockRegistryServers = [ - { - _id: "linear-1", - displayName: "Linear", - publisher: "MCPJam", - transport: { - transportType: "http", - url: "https://mcp.linear.app/mcp", - useOAuth: true, - }, - }, - ]; + mockCatalogCards = [createLinearCatalogCard()]; writePendingQuickConnect({ serverName: "Linear", registryServerId: "linear-1", @@ -613,18 +731,7 @@ describe("ServersTab shared detail modal", () => { it("shows finishing setup copy while the pending quick connect is connecting", () => { mockIsAuthenticated = true; - mockRegistryServers = [ - { - _id: "linear-1", - displayName: "Linear", - publisher: "MCPJam", - transport: { - transportType: "http", - url: "https://mcp.linear.app/mcp", - useOAuth: true, - }, - }, - ]; + mockCatalogCards = [createLinearCatalogCard()]; writePendingQuickConnect({ serverName: "Linear", registryServerId: "linear-1", @@ -665,18 +772,7 @@ describe("ServersTab shared detail modal", () => { it("clears pending quick connect UI once the server is fully connected", () => { mockIsAuthenticated = true; - mockRegistryServers = [ - { - _id: "linear-1", - displayName: "Linear", - publisher: "MCPJam", - transport: { - transportType: "http", - url: "https://mcp.linear.app/mcp", - useOAuth: true, - }, - }, - ]; + mockCatalogCards = [createLinearCatalogCard(), createNotionCatalogCard()]; writePendingQuickConnect({ serverName: "Linear", registryServerId: "linear-1", @@ -708,7 +804,270 @@ describe("ServersTab shared detail modal", () => { ); expect(screen.queryByText("Connecting Linear...")).not.toBeInTheDocument(); - expect(screen.queryByText("Quick Connect")).not.toBeInTheDocument(); + expect(screen.getByText("Quick Connect")).toBeInTheDocument(); + const mini = screen.getAllByTestId("servers-quick-connect-mini-card"); + expect(mini).toHaveLength(1); + expect(mini[0]).toHaveTextContent("Notion"); expect(localStorage.getItem("mcp-quick-connect-pending")).toBeNull(); }); + + it("shows full quick connect with one or two servers and a minimized strip with three or more", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createLinearCatalogCard()]; + + const s1 = createServer({ name: "a" }); + const s2 = createServer({ name: "b" }); + const three = { a: s1, b: s2, c: createServer({ name: "c" }) }; + + const { rerender } = render( + , + ); + expect(screen.getByTestId("servers-quick-connect-section")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-section"), + ).not.toHaveAttribute("data-minimized", "true"); + expect(screen.getByTestId("servers-quick-connect-mini-card")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.getByTestId("servers-quick-connect-section")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-section"), + ).not.toHaveAttribute("data-minimized", "true"); + expect(screen.getByTestId("servers-quick-connect-mini-card")).toBeInTheDocument(); + + rerender( + , + ); + const minimized = screen.getByTestId("servers-quick-connect-section"); + expect(minimized).toBeInTheDocument(); + expect(minimized).toHaveAttribute("data-minimized", "true"); + expect(screen.getByTestId("servers-quick-connect-mini-cards-toggle")).toHaveTextContent( + /Show \(1\)/, + ); + expect( + screen.queryByTestId("servers-tab-browse-registry-header-fallback"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("servers-quick-connect-mini-card"), + ).not.toBeInTheDocument(); + fireEvent.click(screen.getByTestId("servers-quick-connect-mini-cards-toggle")); + expect(screen.getByTestId("servers-quick-connect-mini-card")).toBeInTheDocument(); + expect(screen.getByTestId("servers-quick-connect-mini-cards-toggle")).toHaveTextContent( + /Hide \(1\)/, + ); + }); + + it("keeps quick connect visible with three or more servers while a quick connect is pending", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createLinearCatalogCard()]; + writePendingQuickConnect({ + serverName: "Linear", + registryServerId: "linear-1", + displayName: "Linear", + sourceTab: "servers", + createdAt: 123, + }); + + const s1 = createServer({ name: "a" }); + const s2 = createServer({ name: "b" }); + const s3 = createServer({ name: "c" }); + const three = { a: s1, b: s2, c: s3 }; + + render( + , + ); + + expect(screen.getByTestId("servers-quick-connect-section")).toBeInTheDocument(); + expect( + screen.queryByTestId("servers-tab-browse-registry-header-fallback"), + ).not.toBeInTheDocument(); + }); + + it("renders mini-card metadata and read-only star count on the Servers tab", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createLinearCatalogCard()]; + + render(); + + expect(screen.getByText("Linear")).toBeInTheDocument(); + expect(screen.getByText("MCPJam")).toBeInTheDocument(); + expect(screen.getByLabelText("Verified publisher")).toBeInTheDocument(); + expect(screen.getByLabelText("42 stars")).toBeInTheDocument(); + expect( + screen.getByText("Interact with Linear issues."), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /star this server/i }), + ).not.toBeInTheDocument(); + }); + + it("renders a compact connect dropdown for dual-type catalog cards", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createDualTypeCatalogCard()]; + + render(); + + expect(screen.getByTestId("connect-dropdown-trigger")).toBeInTheDocument(); + }); + + it("shows quick connect and browse registry for guests when catalog is available", () => { + mockIsAuthenticated = false; + mockCatalogCards = [createLinearCatalogCard()]; + + const { rerender } = render( + , + ); + expect(screen.getByTestId("servers-quick-connect-section")).toBeInTheDocument(); + + const s1 = createServer({ name: "a" }); + const s2 = createServer({ name: "b" }); + const s3 = createServer({ name: "c" }); + const three = { a: s1, b: s2, c: s3 }; + + rerender( + , + ); + + const minimized = screen.getByTestId("servers-quick-connect-section"); + expect(minimized).toBeInTheDocument(); + expect(minimized).toHaveAttribute("data-minimized", "true"); + expect( + screen.queryByTestId("servers-tab-browse-registry-header-fallback"), + ).not.toBeInTheDocument(); + }); + + it("passes the shared workspace id to registry queries instead of the local workspace key", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createLinearCatalogCard()]; + + render( + , + ); + + expect(mockUseRegistryServers).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: "ws_shared_123", + }), + ); + }); + + it("skips Convex workspace registry queries when the active workspace is local-only", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createLinearCatalogCard()]; + + render( + , + ); + + expect(mockUseRegistryServers).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: null, + }), + ); + }); + + it("excludes single-variant quick connect cards when that server is already in the workspace", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createLinearCatalogCard()]; + + render( + , + ); + + expect( + screen.queryByTestId("servers-quick-connect-section"), + ).not.toBeInTheDocument(); + }); + + it("excludes a dual-type quick connect card when any variant is already in the workspace", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createDualTypeCatalogCard()]; + + render( + , + ); + + expect( + screen.queryByTestId("servers-quick-connect-section"), + ).not.toBeInTheDocument(); + }); }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts b/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts index 809fb053e..c08ccaa1a 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts +++ b/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { consolidateServers, + sortRegistryVariantsAppBeforeText, type EnrichedRegistryServer, } from "../useRegistryServers"; @@ -32,6 +33,30 @@ function makeServer( }; } +describe("sortRegistryVariantsAppBeforeText", () => { + it("orders app before text regardless of input order", () => { + const text = makeServer({ + _id: "asana-text", + displayName: "Asana", + clientType: "text", + }); + const app = makeServer({ + _id: "asana-app", + displayName: "Asana", + clientType: "app", + }); + + expect(sortRegistryVariantsAppBeforeText([text, app]).map((v) => v._id)).toEqual([ + "asana-app", + "asana-text", + ]); + expect(sortRegistryVariantsAppBeforeText([app, text]).map((v) => v._id)).toEqual([ + "asana-app", + "asana-text", + ]); + }); +}); + describe("consolidateServers", () => { it("returns single-type servers unchanged", () => { const linear = makeServer({ diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index 3cfd33fe5..13f0e4d53 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -228,6 +228,18 @@ export interface ConsolidatedRegistryServer { hasDualType: boolean; } +/** + * App before Text everywhere we list variants (badges, Connect dropdown, primary `variants[0]`). + */ +export function sortRegistryVariantsAppBeforeText< + T extends { clientType?: "text" | "app" }, +>(variants: T[]): T[] { + return [...variants].sort((a, b) => { + const rank = (v: T) => (v.clientType === "app" ? 0 : 1); + return rank(a) - rank(b); + }); +} + /** * Groups registry servers by displayName. Variants are ordered app before text. * Used for dev mock data only; production catalog is consolidated by the backend. @@ -250,9 +262,7 @@ export function consolidateServers( const result: ConsolidatedRegistryServer[] = []; for (const variants of groups.values()) { - const ordered = [...variants].sort((a) => - a.clientType === "app" ? -1 : 1, - ); + const ordered = sortRegistryVariantsAppBeforeText(variants); result.push({ variants: ordered, hasDualType: variants.length > 1 }); } @@ -290,7 +300,7 @@ function enrichCatalogCards( liveServers?: Record, ): EnrichedRegistryCatalogCard[] { return cards.map((card) => { - const variants: EnrichedRegistryServer[] = card.variants.map((server) => { + const mapped: EnrichedRegistryServer[] = card.variants.map((server) => { const isAddedToWorkspace = connectedRegistryIds.has(server._id); const liveServer = liveServers?.[getRegistryServerName(server)]; let connectionStatus: RegistryConnectionStatus = "not_connected"; @@ -305,6 +315,7 @@ function enrichCatalogCards( return { ...server, connectionStatus }; }); + const variants = sortRegistryVariantsAppBeforeText(mapped); return { registryCardKey: card.registryCardKey, diff --git a/mcpjam-inspector/client/src/lib/__tests__/quick-connect-catalog-sort.test.ts b/mcpjam-inspector/client/src/lib/__tests__/quick-connect-catalog-sort.test.ts new file mode 100644 index 000000000..d9774a054 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/__tests__/quick-connect-catalog-sort.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import type { EnrichedRegistryCatalogCard } from "@/hooks/useRegistryServers"; +import { compareQuickConnectCatalogCards } from "../quick-connect-catalog-sort"; + +function card( + displayName: string, + opts: { + catalogSortOrder: number; + clientTypes: ("app" | "text")[]; + }, +): EnrichedRegistryCatalogCard { + const variants = opts.clientTypes.map((clientType, i) => ({ + _id: `${displayName}-${i}`, + name: `mcp.${displayName}.${i}`, + displayName, + description: "", + publisher: "x", + publishStatus: "verified" as const, + scope: "global" as const, + transport: { + transportType: "http" as const, + url: "https://example.com", + useOAuth: true, + }, + status: "approved" as const, + createdBy: "u", + createdAt: 0, + updatedAt: 0, + connectionStatus: "not_connected" as const, + clientType, + })); + return { + registryCardKey: `card-${displayName}`, + catalogSortOrder: opts.catalogSortOrder, + variants, + starCount: 0, + isStarred: false, + hasDualType: opts.clientTypes.length > 1, + }; +} + +describe("compareQuickConnectCatalogCards", () => { + it("orders App-capable cards before text-only when catalogSortOrder would disagree", () => { + const textFirst = card("Zebra", { catalogSortOrder: 0, clientTypes: ["text"] }); + const appLater = card("Acme", { catalogSortOrder: 99, clientTypes: ["app"] }); + const sorted = [textFirst, appLater].sort(compareQuickConnectCatalogCards); + expect(sorted[0].variants[0].displayName).toBe("Acme"); + expect(sorted[1].variants[0].displayName).toBe("Zebra"); + }); + + it("places Excalidraw before Asana before other App servers", () => { + const asana = card("Asana", { catalogSortOrder: 0, clientTypes: ["app"] }); + const notion = card("Notion", { catalogSortOrder: 0, clientTypes: ["app"] }); + const excalidraw = card("Excalidraw", { + catalogSortOrder: 99, + clientTypes: ["app"], + }); + const sorted = [asana, notion, excalidraw].sort( + compareQuickConnectCatalogCards, + ); + expect(sorted.map((c) => c.variants[0].displayName)).toEqual([ + "Excalidraw", + "Asana", + "Notion", + ]); + }); + + it("uses catalogSortOrder among non-pinned App servers", () => { + const b = card("Bravo", { catalogSortOrder: 2, clientTypes: ["app"] }); + const a = card("Alpha", { catalogSortOrder: 1, clientTypes: ["app"] }); + const sorted = [b, a].sort(compareQuickConnectCatalogCards); + expect(sorted.map((c) => c.variants[0].displayName)).toEqual([ + "Alpha", + "Bravo", + ]); + }); +}); diff --git a/mcpjam-inspector/client/src/lib/quick-connect-catalog-sort.ts b/mcpjam-inspector/client/src/lib/quick-connect-catalog-sort.ts new file mode 100644 index 000000000..c05440bb6 --- /dev/null +++ b/mcpjam-inspector/client/src/lib/quick-connect-catalog-sort.ts @@ -0,0 +1,38 @@ +import type { EnrichedRegistryCatalogCard } from "@/hooks/useRegistryServers"; + +/** Pinned to the front among same-tier Quick Connect cards (after App preference). */ +const QUICK_CONNECT_PINNED_DISPLAY_NAMES = ["Excalidraw", "Asana"] as const; + +const UNPINNED_RANK = QUICK_CONNECT_PINNED_DISPLAY_NAMES.length; + +function catalogCardHasAppVariant(card: EnrichedRegistryCatalogCard): boolean { + return card.variants.some((v) => v.clientType === "app"); +} + +function quickConnectPinnedRank(displayName: string): number { + const idx = QUICK_CONNECT_PINNED_DISPLAY_NAMES.indexOf( + displayName as (typeof QUICK_CONNECT_PINNED_DISPLAY_NAMES)[number], + ); + return idx === -1 ? UNPINNED_RANK : idx; +} + +/** + * Sort order for Servers tab Quick Connect: App-capable cards first, then pinned + * Excalidraw → Asana, then remaining cards by {@link EnrichedRegistryCatalogCard.catalogSortOrder}. + */ +export function compareQuickConnectCatalogCards( + a: EnrichedRegistryCatalogCard, + b: EnrichedRegistryCatalogCard, +): number { + const appA = catalogCardHasAppVariant(a) ? 0 : 1; + const appB = catalogCardHasAppVariant(b) ? 0 : 1; + if (appA !== appB) return appA - appB; + + const nameA = a.variants[0]?.displayName ?? ""; + const nameB = b.variants[0]?.displayName ?? ""; + const pinA = quickConnectPinnedRank(nameA); + const pinB = quickConnectPinnedRank(nameB); + if (pinA !== pinB) return pinA - pinB; + + return a.catalogSortOrder - b.catalogSortOrder; +} From a0840f78a1467690fda7b33b664f4e86c5c96289 Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Wed, 25 Mar 2026 00:24:26 -0700 Subject: [PATCH 19/21] gate behind registry-enabled flag --- mcpjam-inspector/client/src/App.tsx | 16 ++++++++-- .../client/src/components/ServersTab.tsx | 14 ++++++--- .../components/__tests__/ServersTab.test.tsx | 29 +++++++++++++++++++ .../client/src/components/mcp-sidebar.tsx | 4 +++ .../client/src/hooks/useRegistryServers.ts | 25 +++++++++++----- 5 files changed, 74 insertions(+), 14 deletions(-) diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index 1361b1f7d..ee8d63766 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -145,6 +145,7 @@ export default function App() { ); const learningEnabled = useFeatureFlagEnabled("mcpjam-learning"); const clientConfigEnabled = useFeatureFlagEnabled("client-config-enabled"); + const registryEnabled = useFeatureFlagEnabled("registry-enabled"); const { getAccessToken, signIn, @@ -657,6 +658,11 @@ export default function App() { )} plan. Upgrade the organization to continue.`, ); applyNavigation("servers", { updateHash: true }); + } else if ( + activeTab === "registry" && + registryEnabled !== true + ) { + applyNavigation("servers", { updateHash: true }); } else if ( activeTab === "learning" && (learningEnabled !== true || !isAuthenticated) @@ -671,6 +677,7 @@ export default function App() { }, [ ciEvalsEnabled, clientConfigEnabled, + registryEnabled, activeTabBillingFeature, activeTabBillingLocked, learningEnabled, @@ -892,10 +899,15 @@ export default function App() { isLoadingWorkspaces={isLoadingRemoteWorkspaces} onWorkspaceShared={handleWorkspaceShared} onLeaveWorkspace={() => handleLeaveWorkspace(activeWorkspaceId)} - onNavigateToRegistry={() => handleNavigate("registry")} + isRegistryEnabled={registryEnabled === true} + onNavigateToRegistry={ + registryEnabled === true + ? () => handleNavigate("registry") + : undefined + } /> )} - {activeTab === "registry" && ( + {activeTab === "registry" && registryEnabled === true && ( void; onLeaveWorkspace?: () => void; + isRegistryEnabled?: boolean; onNavigateToRegistry?: () => void; } @@ -389,6 +390,7 @@ export function ServersTab({ activeWorkspaceId, isLoadingWorkspaces, onWorkspaceShared, + isRegistryEnabled = false, onNavigateToRegistry, }: ServersTabProps) { const posthog = usePostHog(); @@ -403,6 +405,7 @@ export function ServersTab({ isLoading: isRegistryCatalogLoading, connect: connectRegistryServer, } = useRegistryServers({ + enabled: isRegistryEnabled, workspaceId: registryWorkspaceId, isAuthenticated, liveServers: workspaceServers, @@ -632,12 +635,15 @@ export function ServersTab({ }, [totalServerCards]); const shouldShowQuickConnect = - isRegistryCatalogLoading || - quickConnectCatalogAvailableCount > 0 || - isPendingQuickConnectVisible; + isRegistryEnabled && + (isRegistryCatalogLoading || + quickConnectCatalogAvailableCount > 0 || + isPendingQuickConnectVisible); const shouldShowBrowseRegistryOnly = - !shouldShowQuickConnect && quickConnectCatalogAvailableCount > 0; + isRegistryEnabled && + !shouldShowQuickConnect && + quickConnectCatalogAvailableCount > 0; const activeWorkspace = workspaces[activeWorkspaceId]; const sharedWorkspaceId = activeWorkspace?.sharedWorkspaceId; diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index cf5f90b07..b1603b0b7 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -384,6 +384,7 @@ describe("ServersTab shared detail modal", () => { isLoadingWorkspaces: false, onWorkspaceShared: vi.fn(), onLeaveWorkspace: vi.fn(), + isRegistryEnabled: true, onNavigateToRegistry: vi.fn(), }; @@ -982,6 +983,32 @@ describe("ServersTab shared detail modal", () => { ).not.toBeInTheDocument(); }); + it("hides quick connect and browse registry when the registry flag is disabled", () => { + mockIsAuthenticated = true; + mockCatalogCards = [createLinearCatalogCard()]; + + render( + , + ); + + expect( + screen.queryByTestId("servers-quick-connect-section"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("servers-tab-browse-registry-header-fallback"), + ).not.toBeInTheDocument(); + expect(mockUseRegistryServers).toHaveBeenCalledWith( + expect.objectContaining({ + enabled: false, + }), + ); + }); + it("passes the shared workspace id to registry queries instead of the local workspace key", () => { mockIsAuthenticated = true; mockCatalogCards = [createLinearCatalogCard()]; @@ -1000,6 +1027,7 @@ describe("ServersTab shared detail modal", () => { expect(mockUseRegistryServers).toHaveBeenCalledWith( expect.objectContaining({ + enabled: true, workspaceId: "ws_shared_123", }), ); @@ -1020,6 +1048,7 @@ describe("ServersTab shared detail modal", () => { expect(mockUseRegistryServers).toHaveBeenCalledWith( expect.objectContaining({ + enabled: true, workspaceId: null, }), ); diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx index 8c69cb2fb..3a481bf3f 100644 --- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx +++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx @@ -138,6 +138,7 @@ const navigationSections: NavSection[] = [ title: "Registry", url: "#registry", icon: LayoutGrid, + featureFlag: "registry-enabled", }, { title: "Chat", @@ -330,6 +331,7 @@ export function MCPSidebar({ const learningFlagEnabled = useFeatureFlagEnabled("mcpjam-learning"); const sandboxesEnabled = useFeatureFlagEnabled("sandboxes-enabled"); const clientConfigEnabled = useFeatureFlagEnabled("client-config-enabled"); + const registryEnabled = useFeatureFlagEnabled("registry-enabled"); const { isAuthenticated } = useConvexAuth(); const learningEnabled = !!learningFlagEnabled && isAuthenticated; const themeMode = usePreferencesStore((s) => s.themeMode); @@ -433,12 +435,14 @@ export function MCPSidebar({ "mcpjam-learning": !!learningEnabled, "sandboxes-enabled": !!sandboxesEnabled && isAuthenticated, "client-config-enabled": !!clientConfigEnabled && isAuthenticated, + "registry-enabled": registryEnabled === true, }), [ ciEvalsEnabled, learningEnabled, sandboxesEnabled, clientConfigEnabled, + registryEnabled, isAuthenticated, ], ); diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index 13f0e4d53..d8eebcaa8 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -360,12 +360,14 @@ function isMissingWorkspaceConnectionError(error: unknown): boolean { * Pattern follows useWorkspaceMutations / useServerMutations in useWorkspaces.ts. */ export function useRegistryServers({ + enabled = true, workspaceId, isAuthenticated, liveServers, onConnect, onDisconnect, }: { + enabled?: boolean; workspaceId: string | null; isAuthenticated: boolean; liveServers?: Record; @@ -401,11 +403,13 @@ export function useRegistryServers({ }, []); useEffect(() => { + if (!enabled) return; if (DEV_MOCK_REGISTRY) return; void loadCatalog(); - }, [loadCatalog]); + }, [enabled, loadCatalog]); useEffect(() => { + if (!enabled) return; if (!HOSTED_MODE || !isAuthenticated || DEV_MOCK_REGISTRY) return; const guestToken = peekStoredGuestToken(); if (!guestToken || mergeRanRef.current) return; @@ -425,11 +429,11 @@ export function useRegistryServers({ toast.error(message); } })(); - }, [isAuthenticated, loadCatalog]); + }, [enabled, isAuthenticated, loadCatalog]); const connections = useQuery( "registryServers:getWorkspaceRegistryConnections" as any, - !DEV_MOCK_REGISTRY && isAuthenticated && workspaceId + enabled && !DEV_MOCK_REGISTRY && isAuthenticated && workspaceId ? ({ workspaceId } as any) : "skip", ) as RegistryServerConnection[] | undefined; @@ -447,6 +451,7 @@ export function useRegistryServers({ }, [connections]); const catalogCards = useMemo(() => { + if (!enabled) return []; if (rawCatalog === null) return []; const enriched = enrichCatalogCards( rawCatalog, @@ -454,9 +459,10 @@ export function useRegistryServers({ liveServers, ); return sortCatalogCards(enriched); - }, [rawCatalog, connectedRegistryIds, liveServers]); + }, [enabled, rawCatalog, connectedRegistryIds, liveServers]); const categories = useMemo(() => { + if (!enabled) return []; const cats = new Set(); for (const card of catalogCards) { for (const v of card.variants) { @@ -464,13 +470,14 @@ export function useRegistryServers({ } } return Array.from(cats).sort(); - }, [catalogCards]); + }, [enabled, catalogCards]); const [pendingServerIds, setPendingServerIds] = useState>( new Map(), ); useEffect(() => { + if (!enabled) return; if (!isAuthenticated || !workspaceId || DEV_MOCK_REGISTRY) return; for (const [registryServerId, serverName] of pendingServerIds) { if (connectedRegistryIds.has(registryServerId)) { @@ -500,18 +507,20 @@ export function useRegistryServers({ pendingServerIds, isAuthenticated, workspaceId, + enabled, connectMutation, connectedRegistryIds, ]); const connectionsAreLoading = + enabled && !DEV_MOCK_REGISTRY && isAuthenticated && !!workspaceId && connections === undefined; const isLoading = - !DEV_MOCK_REGISTRY && (rawCatalog === null || connectionsAreLoading); + enabled && !DEV_MOCK_REGISTRY && (rawCatalog === null || connectionsAreLoading); const toggleStar = useCallback( async (registryCardKey: string) => { @@ -635,8 +644,8 @@ export function useRegistryServers({ /** Flat list of enriched servers for legacy callers / tests */ const registryServers = useMemo( - () => catalogCards.flatMap((c) => c.variants), - [catalogCards], + () => (enabled ? catalogCards.flatMap((c) => c.variants) : []), + [enabled, catalogCards], ); return { From a39a2974a3084c55e16e3e9daadcfa27c1d2959f Mon Sep 17 00:00:00 2001 From: Prathmesh Patel Date: Wed, 25 Mar 2026 00:38:58 -0700 Subject: [PATCH 20/21] Removed all 22 ##TODOClean debug console.log, remove duped canManageMembers, address comments --- mcpjam-inspector/client/src/App.tsx | 3 - .../src/hooks/hosted/use-hosted-oauth-gate.ts | 6 -- .../client/src/hooks/use-server-state.ts | 13 --- .../client/src/hooks/useWorkspaces.ts | 1 - .../client/src/lib/hosted-oauth-callback.ts | 4 - .../client/src/lib/oauth/mcp-oauth.ts | 93 ------------------- 6 files changed, 120 deletions(-) diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index ee8d63766..df3a9a385 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -236,9 +236,6 @@ export default function App() { } clearHostedOAuthPendingState(); - console.log( - "[OAuthDebug] REMOVE mcp-oauth-pending (App.tsx handleOAuthError)", - ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); localStorage.removeItem("mcp-oauth-return-hash"); const returnHash = resolveHostedOAuthReturnHash(callbackContext); diff --git a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts index b84248a38..1b11bf149 100644 --- a/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts +++ b/mcpjam-inspector/client/src/hooks/hosted/use-hosted-oauth-gate.ts @@ -373,9 +373,6 @@ export function useHostedOAuthGate({ if (!result.success) { clearHostedOAuthPendingState(); - console.log( - "[OAuthDebug] REMOVE mcp-oauth-pending (use-hosted-oauth-gate failure)", - ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); localStorage.removeItem("mcp-oauth-return-hash"); localStorage.removeItem(pendingKey); @@ -401,9 +398,6 @@ export function useHostedOAuthGate({ if (accessToken) { clearHostedOAuthPendingState(); - console.log( - "[OAuthDebug] REMOVE mcp-oauth-pending (use-hosted-oauth-gate success)", - ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); localStorage.removeItem("mcp-oauth-return-hash"); localStorage.removeItem(pendingKey); diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts index dceb5e026..7faa16df3 100644 --- a/mcpjam-inspector/client/src/hooks/use-server-state.ts +++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts @@ -154,12 +154,6 @@ export function useServerState({ const failPendingOAuthConnection = useCallback( (errorMessage: string) => { const pendingServerName = localStorage.getItem("mcp-oauth-pending"); - console.log( - "[OAuthDebug] failPendingOAuthConnection:", - pendingServerName, - "error:", - errorMessage, - ); // ##TODOClean if (pendingServerName) { dispatch({ type: "CONNECT_FAILURE", @@ -169,9 +163,6 @@ export function useServerState({ } localStorage.removeItem("mcp-oauth-return-hash"); - console.log( - "[OAuthDebug] REMOVE mcp-oauth-pending (failPendingOAuthConnection)", - ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); return pendingServerName; @@ -547,10 +538,6 @@ export function useServerState({ const handleOAuthCallbackComplete = useCallback( async (code: string) => { const pendingServerName = localStorage.getItem("mcp-oauth-pending"); - console.log( - "[OAuthDebug] handleOAuthCallbackComplete: mcp-oauth-pending =", - pendingServerName, - ); // ##TODOClean try { const result = await handleOAuthCallback(code); diff --git a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts index da5a9b61c..ff4d6a023 100644 --- a/mcpjam-inspector/client/src/hooks/useWorkspaces.ts +++ b/mcpjam-inspector/client/src/hooks/useWorkspaces.ts @@ -197,7 +197,6 @@ export function useWorkspaceMembers({ canManageMembers, isLoading, hasPendingMembers: pendingMembers.length > 0, - canManageMembers, }; } diff --git a/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts b/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts index ff0f9548d..b03aa015f 100644 --- a/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts +++ b/mcpjam-inspector/client/src/lib/hosted-oauth-callback.ts @@ -198,10 +198,6 @@ export function getHostedOAuthCallbackContext(): HostedOAuthCallbackContext | nu } const serverName = localStorage.getItem("mcp-oauth-pending")?.trim() ?? ""; - console.log( - "[OAuthDebug] hosted-oauth-callback: mcp-oauth-pending =", - serverName || "(empty)", - ); // ##TODOClean if (!serverName) { return null; } diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index 93e69bfcc..c0e8283f4 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -248,17 +248,6 @@ function toConvexOAuthPayload( return payload; } -function logOAuthErrorResponse(prefix: string, response: Response): void { - response - .clone() - .text() - .then((body) => { - console.log(prefix, response.status, body || "(empty)"); // ##TODOClean - }) - .catch((error) => { - console.log(prefix, response.status, "failed to read body", error); // ##TODOClean - }); -} async function loadCallbackDiscoveryState( provider: MCPOAuthProvider, @@ -333,22 +322,6 @@ function createOAuthFetchInterceptor( } // For registry servers, route token exchange/refresh through Convex HTTP actions - console.log( - "[OAuthDebug] interceptedFetch:", - url, - "registryServerId:", - routingConfig.registryServerId, - "useRegistryOAuthProxy:", - routingConfig.useRegistryOAuthProxy, - "method:", - method, - "grantType:", - oauthGrantType, - "isOAuth:", - isOAuthRequest, - "isRegistryTokenRequest:", - isRegistryTokenRequest, - ); // ##TODOClean if (isRegistryTokenRequest) { const convexSiteUrl = getConvexSiteUrl(); if (convexSiteUrl) { @@ -356,10 +329,6 @@ function createOAuthFetchInterceptor( serializedBody.grant_type === "refresh_token" ? "/registry/oauth/refresh" : "/registry/oauth/token"; - console.log( - "[OAuthDebug] INTERCEPTING token request → routing to Convex", - endpoint, - ); // ##TODOClean const response = await authFetch(`${convexSiteUrl}${endpoint}`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -370,18 +339,6 @@ function createOAuthFetchInterceptor( ), ), }); - console.log( - "[OAuthDebug] Convex OAuth route status:", - response.status, - "endpoint:", - endpoint, - ); // ##TODOClean - if (!response.ok) { - logOAuthErrorResponse( - "[OAuthDebug] Convex OAuth route error:", - response, - ); - } return response; } } @@ -414,7 +371,6 @@ function createOAuthFetchInterceptor( // If the proxy call itself failed (e.g., auth error), return that response directly if (!response.ok) { - logOAuthErrorResponse("[OAuthDebug] OAuth proxy error:", response); return response; } @@ -606,11 +562,6 @@ export class MCPOAuthProvider implements OAuthClientProvider { captureServerDetailModalOAuthResume(this.serverName); // Store server name for callback recovery localStorage.setItem("mcp-oauth-pending", this.serverName); - console.log( - "[OAuthDebug] SET mcp-oauth-pending =", - this.serverName, - "(redirectToAuthorization)", - ); // ##TODOClean // Store current hash to restore after OAuth callback if (window.location.hash) { localStorage.setItem("mcp-oauth-return-hash", window.location.hash); @@ -620,17 +571,10 @@ export class MCPOAuthProvider implements OAuthClientProvider { async saveCodeVerifier(codeVerifier: string) { localStorage.setItem(`mcp-verifier-${this.serverName}`, codeVerifier); - console.log("[OAuthDebug] SAVED verifier for", this.serverName); // ##TODOClean } codeVerifier(): string { const verifier = localStorage.getItem(`mcp-verifier-${this.serverName}`); - console.log( - "[OAuthDebug] READ verifier for", - this.serverName, - "exists:", - !!verifier, - ); // ##TODOClean if (!verifier) { throw new Error("Code verifier not found"); } @@ -640,13 +584,6 @@ export class MCPOAuthProvider implements OAuthClientProvider { async invalidateCredentials( scope: "all" | "client" | "tokens" | "verifier" | "discovery", ) { - console.log( - "[OAuthDebug] invalidateCredentials:", - scope, - "for", - this.serverName, - new Error().stack, - ); // ##TODOClean switch (scope) { case "all": localStorage.removeItem(`mcp-tokens-${this.serverName}`); @@ -696,15 +633,6 @@ export async function initiateOAuth( options.serverUrl, ); localStorage.setItem("mcp-oauth-pending", options.serverName); - console.log( - "[OAuthDebug] SET mcp-oauth-pending =", - options.serverName, - "registryServerId:", - options.registryServerId, - "clientId:", - options.clientId, - "(initiateOAuth)", - ); // ##TODOClean // Store OAuth configuration (scopes, registryServerId) for recovery if connection fails const oauthConfig = buildStoredOAuthConfig(options); @@ -803,19 +731,9 @@ export async function handleOAuthCallback( ): Promise { // Get pending server name from localStorage (needed before creating interceptor) const serverName = localStorage.getItem("mcp-oauth-pending"); - console.log( - "[OAuthDebug] handleOAuthCallback: mcp-oauth-pending =", - serverName, - ); // ##TODOClean // Read registryServerId from stored OAuth config if present const oauthConfig = readStoredOAuthConfig(serverName); - console.log( - "[OAuthDebug] handleOAuthCallback: registryServerId =", - oauthConfig.registryServerId, - "oauthConfig =", - localStorage.getItem(`mcp-oauth-config-${serverName}`), - ); // ##TODOClean // Build fetch interceptor — routes token requests through Convex for registry servers const fetchFn = createOAuthFetchInterceptor(oauthConfig); @@ -856,13 +774,6 @@ export async function handleOAuthCallback( provider, discoveryState.resourceMetadata, ); - console.log( - "[OAuthDebug] callback token exchange target:", - discoveryState.authorizationServerMetadata?.token_endpoint ?? - `${discoveryState.authorizationServerUrl}/token`, - "resource:", - resource?.toString() ?? "(none)", - ); // ##TODOClean const tokens = await fetchToken( provider, discoveryState.authorizationServerUrl, @@ -876,9 +787,6 @@ export async function handleOAuthCallback( await provider.saveTokens(tokens); // Clean up pending state - console.log( - "[OAuthDebug] REMOVE mcp-oauth-pending (handleOAuthCallback success)", - ); // ##TODOClean localStorage.removeItem("mcp-oauth-pending"); const serverConfig = createServerConfig(serverUrl, tokens); @@ -1093,7 +1001,6 @@ export async function refreshOAuthTokens( * Clears all OAuth data for a server */ export function clearOAuthData(serverName: string): void { - console.log("[OAuthDebug] clearOAuthData:", serverName, new Error().stack); // ##TODOClean localStorage.removeItem(`mcp-tokens-${serverName}`); localStorage.removeItem(`mcp-client-${serverName}`); localStorage.removeItem(`mcp-verifier-${serverName}`); From a6f07f3adbcbf2ad8a19b6fb745a59253ee94f1d Mon Sep 17 00:00:00 2001 From: prathmeshpatel <25394100+prathmeshpatel@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:40:09 +0000 Subject: [PATCH 21/21] style: auto-fix prettier formatting --- mcpjam-inspector/client/src/App.tsx | 5 +- .../client/src/components/RegistryTab.tsx | 5 +- .../client/src/components/ServersTab.tsx | 120 ++++++++------- .../components/__tests__/RegistryTab.test.tsx | 4 +- .../components/__tests__/ServersTab.test.tsx | 50 +++++-- .../__tests__/consolidateServers.test.ts | 14 +- .../client/src/hooks/useRegistryServers.ts | 139 +++++++++--------- .../quick-connect-catalog-sort.test.ts | 15 +- .../client/src/lib/apis/registry-http.ts | 12 +- .../client/src/lib/oauth/mcp-oauth.ts | 1 - 10 files changed, 187 insertions(+), 178 deletions(-) diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index df3a9a385..1a41aca10 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -655,10 +655,7 @@ export default function App() { )} plan. Upgrade the organization to continue.`, ); applyNavigation("servers", { updateHash: true }); - } else if ( - activeTab === "registry" && - registryEnabled !== true - ) { + } else if (activeTab === "registry" && registryEnabled !== true) { applyNavigation("servers", { updateHash: true }); } else if ( activeTab === "learning" && diff --git a/mcpjam-inspector/client/src/components/RegistryTab.tsx b/mcpjam-inspector/client/src/components/RegistryTab.tsx index 12ee8ccea..5ca47785c 100644 --- a/mcpjam-inspector/client/src/components/RegistryTab.tsx +++ b/mcpjam-inspector/client/src/components/RegistryTab.tsx @@ -273,10 +273,7 @@ function RegistryServerCard({ {first.publisher} {isPublisherVerified && ( - + 140 ? `${description.slice(0, 137)}…` : description; - const connectControl = - card.hasDualType ? ( - - - + + + {card.variants.map((v) => ( + void onConnect(v)} > - {isPending ? ( - <> - - {pendingPhaseLabel} - + {v.clientType === "app" ? ( + ) : ( - <> - Connect - - + )} - - - - {card.variants.map((v) => ( - void onConnect(v)} - > - {v.clientType === "app" ? ( - - ) : ( - - )} - Connect as {v.clientType === "app" ? "App" : "Text"} - - ))} - - - ) : ( - - ); + Connect as {v.clientType === "app" ? "App" : "Text"} + + ))} + + + ) : ( + + ); return (
0; const showMiniCardsRow = - hasMiniCardContent && - (!minimized || quickConnectMiniCardsExpanded); + hasMiniCardContent && (!minimized || quickConnectMiniCardsExpanded); const featuredCount = featuredQuickConnectCards.length; const featuredCountForLabel = - isRegistryCatalogLoading && featuredCount === 0 - ? null - : featuredCount; + isRegistryCatalogLoading && featuredCount === 0 ? null : featuredCount; return (
{ expect(readPendingQuickConnect()).toBeNull(); }); expect(screen.queryByText("Connecting")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Connect" }), + ).toBeInTheDocument(); }); it("clears registry pending when oauth-flow exceeds the stale window", async () => { diff --git a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx index b1603b0b7..a8deecafd 100644 --- a/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx +++ b/mcpjam-inspector/client/src/components/__tests__/ServersTab.test.tsx @@ -659,7 +659,9 @@ describe("ServersTab shared detail modal", () => { expect( screen.getByTestId("servers-quick-connect-browse-registry"), ).toBeInTheDocument(); - expect(screen.getByTestId("servers-quick-connect-mini-card")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-mini-card"), + ).toBeInTheDocument(); }); it("keeps quick connect visible after clicking a quick connect server", () => { @@ -684,7 +686,9 @@ describe("ServersTab shared detail modal", () => { expect(screen.getAllByText("Authorizing...").length).toBeGreaterThanOrEqual( 1, ); - expect(screen.getByRole("button", { name: "Connect Linear" })).toBeDisabled(); + expect( + screen.getByRole("button", { name: "Connect Linear" }), + ).toBeDisabled(); }); it("keeps quick connect visible during oauth-flow after return", () => { @@ -829,11 +833,15 @@ describe("ServersTab shared detail modal", () => { }} />, ); - expect(screen.getByTestId("servers-quick-connect-section")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-section"), + ).toBeInTheDocument(); expect( screen.getByTestId("servers-quick-connect-section"), ).not.toHaveAttribute("data-minimized", "true"); - expect(screen.getByTestId("servers-quick-connect-mini-card")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-mini-card"), + ).toBeInTheDocument(); rerender( { }} />, ); - expect(screen.getByTestId("servers-quick-connect-section")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-section"), + ).toBeInTheDocument(); expect( screen.getByTestId("servers-quick-connect-section"), ).not.toHaveAttribute("data-minimized", "true"); - expect(screen.getByTestId("servers-quick-connect-mini-card")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-mini-card"), + ).toBeInTheDocument(); rerender( { const minimized = screen.getByTestId("servers-quick-connect-section"); expect(minimized).toBeInTheDocument(); expect(minimized).toHaveAttribute("data-minimized", "true"); - expect(screen.getByTestId("servers-quick-connect-mini-cards-toggle")).toHaveTextContent( - /Show \(1\)/, - ); + expect( + screen.getByTestId("servers-quick-connect-mini-cards-toggle"), + ).toHaveTextContent(/Show \(1\)/); expect( screen.queryByTestId("servers-tab-browse-registry-header-fallback"), ).not.toBeInTheDocument(); expect( screen.queryByTestId("servers-quick-connect-mini-card"), ).not.toBeInTheDocument(); - fireEvent.click(screen.getByTestId("servers-quick-connect-mini-cards-toggle")); - expect(screen.getByTestId("servers-quick-connect-mini-card")).toBeInTheDocument(); - expect(screen.getByTestId("servers-quick-connect-mini-cards-toggle")).toHaveTextContent( - /Hide \(1\)/, + fireEvent.click( + screen.getByTestId("servers-quick-connect-mini-cards-toggle"), ); + expect( + screen.getByTestId("servers-quick-connect-mini-card"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-mini-cards-toggle"), + ).toHaveTextContent(/Hide \(1\)/); }); it("keeps quick connect visible with three or more servers while a quick connect is pending", () => { @@ -918,7 +934,9 @@ describe("ServersTab shared detail modal", () => { />, ); - expect(screen.getByTestId("servers-quick-connect-section")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-section"), + ).toBeInTheDocument(); expect( screen.queryByTestId("servers-tab-browse-registry-header-fallback"), ).not.toBeInTheDocument(); @@ -958,7 +976,9 @@ describe("ServersTab shared detail modal", () => { const { rerender } = render( , ); - expect(screen.getByTestId("servers-quick-connect-section")).toBeInTheDocument(); + expect( + screen.getByTestId("servers-quick-connect-section"), + ).toBeInTheDocument(); const s1 = createServer({ name: "a" }); const s2 = createServer({ name: "b" }); diff --git a/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts b/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts index c08ccaa1a..fd4eb311a 100644 --- a/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts +++ b/mcpjam-inspector/client/src/hooks/__tests__/consolidateServers.test.ts @@ -46,14 +46,12 @@ describe("sortRegistryVariantsAppBeforeText", () => { clientType: "app", }); - expect(sortRegistryVariantsAppBeforeText([text, app]).map((v) => v._id)).toEqual([ - "asana-app", - "asana-text", - ]); - expect(sortRegistryVariantsAppBeforeText([app, text]).map((v) => v._id)).toEqual([ - "asana-app", - "asana-text", - ]); + expect( + sortRegistryVariantsAppBeforeText([text, app]).map((v) => v._id), + ).toEqual(["asana-app", "asana-text"]); + expect( + sortRegistryVariantsAppBeforeText([app, text]).map((v) => v._id), + ).toEqual(["asana-app", "asana-text"]); }); }); diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts index d8eebcaa8..20d1b8fa5 100644 --- a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -278,7 +278,9 @@ export function getRegistryServerName(server: RegistryServer): string { return server.displayName; } -function sortRawCatalogCards(cards: RegistryCatalogCard[]): RegistryCatalogCard[] { +function sortRawCatalogCards( + cards: RegistryCatalogCard[], +): RegistryCatalogCard[] { return [...cards].sort((a, b) => { if (a.isStarred !== b.isStarred) return a.isStarred ? -1 : 1; return a.catalogSortOrder - b.catalogSortOrder; @@ -329,9 +331,10 @@ function enrichCatalogCards( } function buildMockCatalogCards(): RegistryCatalogCard[] { - const enriched: EnrichedRegistryServer[] = MOCK_REGISTRY_SERVERS.map( - (s) => ({ ...s, connectionStatus: "not_connected" as const }), - ); + const enriched: EnrichedRegistryServer[] = MOCK_REGISTRY_SERVERS.map((s) => ({ + ...s, + connectionStatus: "not_connected" as const, + })); const consolidated = consolidateServers(enriched); return consolidated.map((c, i) => ({ registryCardKey: `mock:${c.variants[0].displayName}:${i}`, @@ -348,9 +351,7 @@ function buildMockCatalogCards(): RegistryCatalogCard[] { function isMissingWorkspaceConnectionError(error: unknown): boolean { return ( error instanceof Error && - error.message.includes( - "Registry server is not connected to this workspace", - ) + error.message.includes("Registry server is not connected to this workspace") ); } @@ -520,87 +521,81 @@ export function useRegistryServers({ connections === undefined; const isLoading = - enabled && !DEV_MOCK_REGISTRY && (rawCatalog === null || connectionsAreLoading); + enabled && + !DEV_MOCK_REGISTRY && + (rawCatalog === null || connectionsAreLoading); - const toggleStar = useCallback( - async (registryCardKey: string) => { - if (DEV_MOCK_REGISTRY) return; + const toggleStar = useCallback(async (registryCardKey: string) => { + if (DEV_MOCK_REGISTRY) return; + + const priorStarState: { + current: { isStarred: boolean; starCount: number } | null; + } = { current: null }; + + setRawCatalog((prev) => { + if (!prev) return prev; + const card = prev.find((c) => c.registryCardKey === registryCardKey); + if (!card) return prev; + priorStarState.current = { + isStarred: card.isStarred, + starCount: card.starCount, + }; + const nextStarred = !card.isStarred; + const nextCount = Math.max(0, card.starCount + (nextStarred ? 1 : -1)); + return sortRawCatalogCards( + prev.map((c) => + c.registryCardKey === registryCardKey + ? { + ...c, + isStarred: nextStarred, + starCount: nextCount, + } + : c, + ), + ); + }); - const priorStarState: { - current: { isStarred: boolean; starCount: number } | null; - } = { current: null }; + const snapshot = priorStarState.current; + if (!snapshot) return; + try { + const result = snapshot.isStarred + ? await unstarRegistryCard(registryCardKey) + : await starRegistryCard(registryCardKey); setRawCatalog((prev) => { if (!prev) return prev; - const card = prev.find((c) => c.registryCardKey === registryCardKey); - if (!card) return prev; - priorStarState.current = { - isStarred: card.isStarred, - starCount: card.starCount, - }; - const nextStarred = !card.isStarred; - const nextCount = Math.max( - 0, - card.starCount + (nextStarred ? 1 : -1), + return sortRawCatalogCards( + prev.map((c) => + c.registryCardKey === registryCardKey + ? { + ...c, + isStarred: result.isStarred, + starCount: result.starCount, + } + : c, + ), ); + }); + } catch (error) { + setRawCatalog((prev) => { + if (!prev) return prev; return sortRawCatalogCards( prev.map((c) => c.registryCardKey === registryCardKey ? { ...c, - isStarred: nextStarred, - starCount: nextCount, + isStarred: snapshot.isStarred, + starCount: snapshot.starCount, } : c, ), ); }); - - const snapshot = priorStarState.current; - if (!snapshot) return; - - try { - const result = snapshot.isStarred - ? await unstarRegistryCard(registryCardKey) - : await starRegistryCard(registryCardKey); - setRawCatalog((prev) => { - if (!prev) return prev; - return sortRawCatalogCards( - prev.map((c) => - c.registryCardKey === registryCardKey - ? { - ...c, - isStarred: result.isStarred, - starCount: result.starCount, - } - : c, - ), - ); - }); - } catch (error) { - setRawCatalog((prev) => { - if (!prev) return prev; - return sortRawCatalogCards( - prev.map((c) => - c.registryCardKey === registryCardKey - ? { - ...c, - isStarred: snapshot.isStarred, - starCount: snapshot.starCount, - } - : c, - ), - ); - }); - const message = - error instanceof WebApiError - ? error.message - : "Could not update star"; - toast.error(message); - } - }, - [], - ); + const message = + error instanceof WebApiError ? error.message : "Could not update star"; + toast.error(message); + } + }, []); async function connect(server: RegistryServer) { const serverName = getRegistryServerName(server); diff --git a/mcpjam-inspector/client/src/lib/__tests__/quick-connect-catalog-sort.test.ts b/mcpjam-inspector/client/src/lib/__tests__/quick-connect-catalog-sort.test.ts index d9774a054..e86c6313a 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/quick-connect-catalog-sort.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/quick-connect-catalog-sort.test.ts @@ -41,8 +41,14 @@ function card( describe("compareQuickConnectCatalogCards", () => { it("orders App-capable cards before text-only when catalogSortOrder would disagree", () => { - const textFirst = card("Zebra", { catalogSortOrder: 0, clientTypes: ["text"] }); - const appLater = card("Acme", { catalogSortOrder: 99, clientTypes: ["app"] }); + const textFirst = card("Zebra", { + catalogSortOrder: 0, + clientTypes: ["text"], + }); + const appLater = card("Acme", { + catalogSortOrder: 99, + clientTypes: ["app"], + }); const sorted = [textFirst, appLater].sort(compareQuickConnectCatalogCards); expect(sorted[0].variants[0].displayName).toBe("Acme"); expect(sorted[1].variants[0].displayName).toBe("Zebra"); @@ -50,7 +56,10 @@ describe("compareQuickConnectCatalogCards", () => { it("places Excalidraw before Asana before other App servers", () => { const asana = card("Asana", { catalogSortOrder: 0, clientTypes: ["app"] }); - const notion = card("Notion", { catalogSortOrder: 0, clientTypes: ["app"] }); + const notion = card("Notion", { + catalogSortOrder: 0, + clientTypes: ["app"], + }); const excalidraw = card("Excalidraw", { catalogSortOrder: 99, clientTypes: ["app"], diff --git a/mcpjam-inspector/client/src/lib/apis/registry-http.ts b/mcpjam-inspector/client/src/lib/apis/registry-http.ts index b8ac94b14..a5428c716 100644 --- a/mcpjam-inspector/client/src/lib/apis/registry-http.ts +++ b/mcpjam-inspector/client/src/lib/apis/registry-http.ts @@ -38,11 +38,9 @@ async function readJsonBody(response: Response): Promise { } } -function throwFromFailedResponse( - response: Response, - body: unknown, -): never { - const record = body && typeof body === "object" ? (body as Record) : null; +function throwFromFailedResponse(response: Response, body: unknown): never { + const record = + body && typeof body === "object" ? (body as Record) : null; const code = typeof record?.code === "string" ? record.code @@ -75,9 +73,7 @@ export async function fetchRegistryCatalog( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify( - category === undefined || category === null - ? {} - : { category }, + category === undefined || category === null ? {} : { category }, ), }); const body = await readJsonBody(response); diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index c0e8283f4..331970544 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -248,7 +248,6 @@ function toConvexOAuthPayload( return payload; } - async function loadCallbackDiscoveryState( provider: MCPOAuthProvider, serverUrl: string,