diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx index 8f58a3f54..c6fa66ce9 100644 --- a/mcpjam-inspector/client/src/App.tsx +++ b/mcpjam-inspector/client/src/App.tsx @@ -23,6 +23,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"; @@ -686,6 +687,17 @@ export default function App() { 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 6641f18dc..2e1fce5b6 100644 --- a/mcpjam-inspector/client/src/components/ServersTab.tsx +++ b/mcpjam-inspector/client/src/components/ServersTab.tsx @@ -1,7 +1,7 @@ import { 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 } from "@/hooks/use-app-state"; import { ServerConnectionCard } from "./connection/ServerConnectionCard"; import { AddServerModal } from "./connection/AddServerModal"; @@ -10,6 +10,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 { WorkspaceMembersFacepile } from "./workspace/WorkspaceMembersFacepile"; @@ -147,6 +149,7 @@ interface ServersTabProps { isLoadingWorkspaces?: boolean; onWorkspaceShared?: (sharedWorkspaceId: string) => void; onLeaveWorkspace?: () => void; + onNavigateToRegistry?: () => void; } export function ServersTab({ @@ -165,10 +168,22 @@ export function ServersTab({ isLoadingWorkspaces, onWorkspaceShared, onLeaveWorkspace, + onNavigateToRegistry, }: ServersTabProps) { const posthog = usePostHog(); const { isAuthenticated } = useConvexAuth(); const { user } = useAuth(); + + // 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); @@ -493,6 +508,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..53985ff58 --- /dev/null +++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx @@ -0,0 +1,459 @@ +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 8d44254ba..5713b7d38 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"; @@ -94,6 +95,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/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx index 74457882d..e88223f92 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 @@ -3,11 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AppState, AppAction } from "@/state/app-types"; 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 } = vi.hoisted( + () => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), + handleOAuthCallbackMock: vi.fn(), + }), +); vi.mock("sonner", () => ({ toast: { diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts new file mode 100644 index 000000000..51a396ab8 --- /dev/null +++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts @@ -0,0 +1,326 @@ +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 0cf3e6f6b..3665af38e 100644 --- a/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts +++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts @@ -6,7 +6,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"]); }); @@ -37,7 +37,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("allows ci-evals in hosted mode", () => { 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 2c0560de0..00a369ab9 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 5085bf428..2fc469deb 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/__tests__/debug-oauth-client-metadata.test.ts b/mcpjam-inspector/client/src/lib/oauth/__tests__/debug-oauth-client-metadata.test.ts index f307ac39b..deecdc98d 100644 --- a/mcpjam-inspector/client/src/lib/oauth/__tests__/debug-oauth-client-metadata.test.ts +++ b/mcpjam-inspector/client/src/lib/oauth/__tests__/debug-oauth-client-metadata.test.ts @@ -11,7 +11,10 @@ import { createDebugOAuthStateMachine as createDebugOAuthStateMachine20251125, EMPTY_OAUTH_FLOW_STATE_V2 as EMPTY_OAUTH_FLOW_STATE_20251125, } from "../state-machines/debug-oauth-2025-11-25"; -import type { OAuthFlowState, OAuthStateMachine } from "../state-machines/types"; +import type { + OAuthFlowState, + OAuthStateMachine, +} from "../state-machines/types"; const EXPECTED_LOGO_URI = "https://www.mcpjam.com/mcp_jam_2row.png"; const REDIRECT_URI = "https://app.mcpjam.com/oauth/callback/debug"; @@ -98,26 +101,29 @@ describe("OAuth debugger DCR client metadata", () => { emptyState: EMPTY_OAUTH_FLOW_STATE_20251125, expectedClientName: "MCPJam Inspector Debug Client", }, - ])("%s includes logo_uri in the DCR registration payload", async (flowCase) => { - vi.useFakeTimers(); + ])( + "%s includes logo_uri in the DCR registration payload", + async (flowCase) => { + vi.useFakeTimers(); - const { machine, getState } = createStateMachineHarness(flowCase); + const { machine, getState } = createStateMachineHarness(flowCase); - await machine.proceedToNextStep(); + await machine.proceedToNextStep(); - expect(getState().currentStep).toBe("request_client_registration"); - expect(getState().lastRequest).toMatchObject({ - method: "POST", - url: REGISTRATION_ENDPOINT, - body: { - client_name: flowCase.expectedClientName, - logo_uri: EXPECTED_LOGO_URI, - redirect_uris: [REDIRECT_URI], - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - token_endpoint_auth_method: "none", - scope: "read write", - }, - }); - }); + expect(getState().currentStep).toBe("request_client_registration"); + expect(getState().lastRequest).toMatchObject({ + method: "POST", + url: REGISTRATION_ENDPOINT, + body: { + client_name: flowCase.expectedClientName, + logo_uri: EXPECTED_LOGO_URI, + redirect_uris: [REDIRECT_URI], + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "read write", + }, + }); + }, + ); }); diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts index 2dd05d41c..72063bbc5 100644 --- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts +++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts @@ -15,6 +15,19 @@ import { HOSTED_MODE } from "@/lib/config"; // 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; @@ -29,9 +42,11 @@ 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, @@ -52,6 +67,33 @@ 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/"); @@ -115,6 +157,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 { @@ -306,7 +350,9 @@ 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 { @@ -324,11 +370,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), @@ -422,13 +471,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"); } @@ -575,8 +633,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 {