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.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.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 ? (

) : (
@@ -176,20 +187,14 @@ function RegistryServerCard({
)}
-
-
- {server.displayName}
-
-
-
- {server.category}
-
-
+
+ {first.displayName}
+
- {server.publisher}
+ {first.publisher}
- {server.publisher === "MCPJam" && (
+ {first.publisher === "MCPJam" && (
+ {/* 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) => (
-
)}
@@ -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
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 */}
-
+
+
void onToggleStar(card.registryCardKey)}
+ aria-label={
+ card.isStarred ? "Remove from starred" : "Star this server"
+ }
+ aria-pressed={card.isStarred}
+ >
+
+
+ {formatRegistryStarCount(card.starCount)}
+
+
{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