diff --git a/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx
new file mode 100644
index 000000000..53985ff58
--- /dev/null
+++ b/mcpjam-inspector/client/src/components/__tests__/RegistryTab.test.tsx
@@ -0,0 +1,459 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { RegistryTab } from "../RegistryTab";
+import type { EnrichedRegistryServer } from "@/hooks/useRegistryServers";
+
+// Mock the useRegistryServers hook
+const mockConnect = vi.fn();
+const mockDisconnect = vi.fn();
+let mockHookReturn: {
+ registryServers: EnrichedRegistryServer[];
+ categories: string[];
+ isLoading: boolean;
+ connect: typeof mockConnect;
+ disconnect: typeof mockDisconnect;
+};
+
+vi.mock("@/hooks/useRegistryServers", () => ({
+ useRegistryServers: () => mockHookReturn,
+}));
+
+// Mock dropdown menu to simplify testing
+vi.mock("../ui/dropdown-menu", () => ({
+ DropdownMenu: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
+
{children}
+ ),
+ DropdownMenuItem: ({
+ children,
+ onClick,
+ }: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ }) => (
+
+ ),
+}));
+
+function createMockServer(
+ overrides: Partial
= {},
+): EnrichedRegistryServer {
+ return {
+ _id: "server_1",
+ slug: "test-server",
+ displayName: "Test Server",
+ description: "A test MCP server for unit tests.",
+ publisher: "TestCo",
+ category: "Productivity",
+ transport: { type: "http", url: "https://mcp.test.com/sse" },
+ approved: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ connectionStatus: "not_connected",
+ ...overrides,
+ };
+}
+
+describe("RegistryTab", () => {
+ const defaultProps = {
+ workspaceId: "ws_123",
+ isAuthenticated: true,
+ onConnect: vi.fn(),
+ onDisconnect: vi.fn(),
+ onNavigate: vi.fn(),
+ servers: {},
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockConnect.mockResolvedValue(undefined);
+ mockDisconnect.mockResolvedValue(undefined);
+ mockHookReturn = {
+ registryServers: [],
+ categories: [],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+ });
+
+ describe("visibility without authentication", () => {
+ it("renders registry servers when not authenticated", () => {
+ const server = createMockServer();
+ mockHookReturn = {
+ registryServers: [server],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByText("Registry")).toBeInTheDocument();
+ expect(screen.getByText("Test Server")).toBeInTheDocument();
+ expect(screen.getByText("TestCo")).toBeInTheDocument();
+ expect(screen.getByText("Connect")).toBeInTheDocument();
+ });
+
+ it("shows header and description when not authenticated", () => {
+ mockHookReturn = {
+ registryServers: [createMockServer()],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByText("Registry")).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ "Pre-configured MCP servers you can connect with one click.",
+ ),
+ ).toBeInTheDocument();
+ });
+ });
+
+ describe("loading state", () => {
+ it("shows loading skeleton when data is loading", () => {
+ mockHookReturn = {
+ registryServers: [],
+ categories: [],
+ isLoading: true,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ const { container } = render();
+
+ const skeletons = container.querySelectorAll("[data-slot='skeleton']");
+ expect(skeletons.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe("empty state", () => {
+ it("shows empty state when no servers are available", () => {
+ render();
+
+ expect(screen.getByText("No servers available")).toBeInTheDocument();
+ });
+ });
+
+ describe("auth badges", () => {
+ it("shows OAuth badge with key icon for OAuth servers", () => {
+ mockHookReturn = {
+ registryServers: [
+ createMockServer({
+ transport: {
+ type: "http",
+ url: "https://mcp.test.com/sse",
+ useOAuth: true,
+ },
+ }),
+ ],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByText("OAuth")).toBeInTheDocument();
+ });
+
+ it("shows No auth badge for servers without OAuth", () => {
+ mockHookReturn = {
+ registryServers: [createMockServer()],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByText("No auth")).toBeInTheDocument();
+ });
+ });
+
+ describe("server cards", () => {
+ it("renders server cards with correct information", () => {
+ const server = createMockServer({
+ displayName: "Linear",
+ description: "Manage Linear issues and projects.",
+ publisher: "MCPJam",
+ category: "Project Management",
+ transport: {
+ type: "http",
+ url: "https://mcp.linear.app/sse",
+ useOAuth: true,
+ },
+ });
+ mockHookReturn = {
+ registryServers: [server],
+ categories: ["Project Management"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByText("Linear")).toBeInTheDocument();
+ expect(
+ screen.getByText("Manage Linear issues and projects."),
+ ).toBeInTheDocument();
+ expect(screen.getByText("MCPJam")).toBeInTheDocument();
+ expect(screen.getByText("Project Management")).toBeInTheDocument();
+ });
+
+ it("does not show raw URL by default", () => {
+ mockHookReturn = {
+ registryServers: [createMockServer()],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(
+ screen.queryByText("https://mcp.test.com/sse"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows Connect button for not_connected servers", () => {
+ mockHookReturn = {
+ registryServers: [createMockServer()],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByText("Connect")).toBeInTheDocument();
+ });
+
+ it("shows Connected badge for connected servers", () => {
+ mockHookReturn = {
+ registryServers: [createMockServer({ connectionStatus: "connected" })],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByText("Connected")).toBeInTheDocument();
+ });
+
+ it("shows Added badge for servers added but not live", () => {
+ mockHookReturn = {
+ registryServers: [createMockServer({ connectionStatus: "added" })],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByText("Added")).toBeInTheDocument();
+ });
+ });
+
+ describe("category filtering", () => {
+ it("shows category filter pills when multiple categories exist", () => {
+ mockHookReturn = {
+ registryServers: [
+ createMockServer({ _id: "1", category: "Productivity" }),
+ createMockServer({ _id: "2", category: "Developer Tools" }),
+ ],
+ categories: ["Developer Tools", "Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Productivity" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Developer Tools" }),
+ ).toBeInTheDocument();
+ });
+
+ it("filters servers when category pill is clicked", () => {
+ const prodServer = createMockServer({
+ _id: "1",
+ displayName: "Notion",
+ category: "Productivity",
+ });
+ const devServer = createMockServer({
+ _id: "2",
+ displayName: "GitHub",
+ category: "Developer Tools",
+ });
+ mockHookReturn = {
+ registryServers: [prodServer, devServer],
+ categories: ["Developer Tools", "Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ expect(screen.getByText("Notion")).toBeInTheDocument();
+ expect(screen.getByText("GitHub")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole("button", { name: "Productivity" }));
+
+ expect(screen.getByText("Notion")).toBeInTheDocument();
+ expect(screen.queryByText("GitHub")).not.toBeInTheDocument();
+ });
+ });
+
+ describe("connect/disconnect actions", () => {
+ it("calls connect when Connect button is clicked", async () => {
+ const server = createMockServer();
+ mockHookReturn = {
+ registryServers: [server],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ fireEvent.click(screen.getByText("Connect"));
+
+ await waitFor(() => {
+ expect(mockConnect).toHaveBeenCalledWith(server);
+ });
+ });
+
+ it("calls disconnect from overflow menu", async () => {
+ const server = createMockServer({ connectionStatus: "connected" });
+ mockHookReturn = {
+ registryServers: [server],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ render();
+
+ // Click disconnect in the mocked dropdown
+ const disconnectItem = screen.getByText("Disconnect");
+ fireEvent.click(disconnectItem);
+
+ await waitFor(() => {
+ expect(mockDisconnect).toHaveBeenCalledWith(server);
+ });
+ });
+ });
+
+ describe("auto-redirect to App Builder", () => {
+ it("navigates to app-builder when a pending server becomes connected", async () => {
+ const server = createMockServer({ displayName: "Asana" });
+ mockHookReturn = {
+ registryServers: [server],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ const onNavigate = vi.fn();
+ const { rerender } = render(
+ ,
+ );
+
+ // Click connect — stores pending redirect in localStorage
+ fireEvent.click(screen.getByText("Connect"));
+ await waitFor(() => expect(mockConnect).toHaveBeenCalled());
+ expect(localStorage.getItem("registry-pending-redirect")).toBe("Asana");
+
+ // Simulate server becoming connected via props update
+ rerender(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(onNavigate).toHaveBeenCalledWith("app-builder");
+ });
+ // localStorage should be cleaned up
+ expect(localStorage.getItem("registry-pending-redirect")).toBeNull();
+ });
+
+ it("survives page remount (OAuth redirect) and still auto-redirects", async () => {
+ // Simulate: user clicked Connect, got redirected to OAuth, page remounted
+ localStorage.setItem("registry-pending-redirect", "Linear");
+
+ const server = createMockServer({ displayName: "Linear" });
+ mockHookReturn = {
+ registryServers: [server],
+ categories: ["Productivity"],
+ isLoading: false,
+ connect: mockConnect,
+ disconnect: mockDisconnect,
+ };
+
+ const onNavigate = vi.fn();
+
+ // Mount with server already connected (OAuth callback completed)
+ render(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(onNavigate).toHaveBeenCalledWith("app-builder");
+ });
+ expect(localStorage.getItem("registry-pending-redirect")).toBeNull();
+ });
+ });
+});
diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx
index 8d44254ba..5713b7d38 100644
--- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx
+++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx
@@ -16,6 +16,7 @@ import {
GitBranch,
GraduationCap,
Box,
+ Package,
} from "lucide-react";
import { usePostHog, useFeatureFlagEnabled } from "posthog-js/react";
@@ -94,6 +95,11 @@ const navigationSections: NavSection[] = [
url: "#servers",
icon: MCPIcon,
},
+ {
+ title: "Registry",
+ url: "#registry",
+ icon: Package,
+ },
{
title: "Chat",
url: "#chat-v2",
diff --git a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx
index 74457882d..e88223f92 100644
--- a/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx
+++ b/mcpjam-inspector/client/src/hooks/__tests__/use-server-state.test.tsx
@@ -3,11 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AppState, AppAction } from "@/state/app-types";
import { useServerState } from "../use-server-state";
-const { toastError, toastSuccess, handleOAuthCallbackMock } = vi.hoisted(() => ({
- toastError: vi.fn(),
- toastSuccess: vi.fn(),
- handleOAuthCallbackMock: vi.fn(),
-}));
+const { toastError, toastSuccess, handleOAuthCallbackMock } = vi.hoisted(
+ () => ({
+ toastError: vi.fn(),
+ toastSuccess: vi.fn(),
+ handleOAuthCallbackMock: vi.fn(),
+ }),
+);
vi.mock("sonner", () => ({
toast: {
diff --git a/mcpjam-inspector/client/src/hooks/useRegistryServers.ts b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts
new file mode 100644
index 000000000..51a396ab8
--- /dev/null
+++ b/mcpjam-inspector/client/src/hooks/useRegistryServers.ts
@@ -0,0 +1,326 @@
+import { useMemo, useCallback } from "react";
+import { useQuery, useMutation } from "convex/react";
+import type { ServerFormData } from "@/shared/types.js";
+
+/**
+ * Dev-only mock registry servers for local UI testing.
+ * Set to `true` to bypass Convex and render sample cards.
+ */
+const DEV_MOCK_REGISTRY = import.meta.env.DEV && true;
+
+const MOCK_REGISTRY_SERVERS: RegistryServer[] = [
+ {
+ _id: "mock_asana",
+ slug: "asana",
+ displayName: "Asana",
+ description:
+ "Connect to Asana to manage tasks, projects, and team workflows directly from your MCP client.",
+ publisher: "MCPJam",
+ category: "Project Management",
+ iconUrl: "https://cdn.worldvectorlogo.com/logos/asana-logo.svg",
+ transport: {
+ type: "http",
+ url: "https://mcp.asana.com/v2/mcp",
+ useOAuth: true,
+ oauthScopes: ["default"],
+ },
+ approved: true,
+ featured: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ {
+ _id: "mock_linear",
+ slug: "linear",
+ displayName: "Linear",
+ description:
+ "Interact with Linear issues, projects, and cycles. Create, update, and search issues with natural language.",
+ publisher: "MCPJam",
+ category: "Project Management",
+ iconUrl: "https://asset.brandfetch.io/iduDa181eM/idYoMflFma.png",
+ transport: {
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ useOAuth: true,
+ oauthScopes: ["read", "write"],
+ },
+ approved: true,
+ featured: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ {
+ _id: "mock_notion",
+ slug: "notion",
+ displayName: "Notion",
+ description:
+ "Access and manage Notion pages, databases, and content. Search, create, and update your workspace.",
+ publisher: "MCPJam",
+ category: "Productivity",
+ iconUrl:
+ "https://upload.wikimedia.org/wikipedia/commons/4/45/Notion_app_logo.png",
+ transport: {
+ type: "http",
+ url: "https://mcp.notion.com/mcp",
+ useOAuth: true,
+ oauthScopes: ["read_content", "update_content"],
+ },
+ approved: true,
+ featured: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ {
+ _id: "mock_slack",
+ slug: "slack",
+ displayName: "Slack",
+ description:
+ "Send messages, search conversations, and manage Slack channels directly through MCP.",
+ publisher: "MCPJam",
+ category: "Communication",
+ iconUrl: "https://cdn.worldvectorlogo.com/logos/slack-new-logo.svg",
+ transport: {
+ type: "http",
+ url: "https://mcp.slack.com/sse",
+ useOAuth: true,
+ },
+ approved: true,
+ featured: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ {
+ _id: "mock_github",
+ slug: "github",
+ displayName: "GitHub",
+ description:
+ "Manage repositories, pull requests, issues, and code reviews. Automate your GitHub workflows.",
+ publisher: "MCPJam",
+ category: "Developer Tools",
+ transport: {
+ type: "http",
+ url: "https://mcp.github.com/sse",
+ useOAuth: true,
+ oauthScopes: ["repo", "read:org"],
+ },
+ approved: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ {
+ _id: "mock_jira",
+ slug: "jira",
+ displayName: "Jira",
+ description:
+ "Create and manage Jira issues, sprints, and boards. Track project progress with natural language.",
+ publisher: "MCPJam",
+ category: "Project Management",
+ transport: {
+ type: "http",
+ url: "https://mcp.atlassian.com/jira/sse",
+ useOAuth: true,
+ },
+ approved: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ {
+ _id: "mock_google_drive",
+ slug: "google-drive",
+ displayName: "Google Drive",
+ description:
+ "Search, read, and organize files in Google Drive. Access documents, spreadsheets, and presentations.",
+ publisher: "MCPJam",
+ category: "Productivity",
+ transport: {
+ type: "http",
+ url: "https://mcp.googleapis.com/drive/sse",
+ useOAuth: true,
+ },
+ approved: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+ {
+ _id: "mock_stripe",
+ slug: "stripe",
+ displayName: "Stripe",
+ description:
+ "Query payments, subscriptions, and customer data. Monitor your Stripe business metrics.",
+ publisher: "MCPJam",
+ category: "Finance",
+ transport: { type: "http", url: "https://mcp.stripe.com/sse" },
+ approved: true,
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ },
+];
+
+/**
+ * Shape of a registry server document from the Convex backend.
+ * Matches the `registryServers` table schema.
+ */
+export interface RegistryServer {
+ _id: string;
+ slug: string;
+ displayName: string;
+ description: string;
+ publisher: string;
+ category: string;
+ iconUrl?: string;
+ transport: {
+ type: "http";
+ url: string;
+ useOAuth?: boolean;
+ oauthScopes?: string[];
+ clientId?: string;
+ };
+ approved: boolean;
+ featured?: boolean;
+ createdAt: number;
+ updatedAt: number;
+}
+
+/**
+ * Shape of a registry server connection from `registryServerConnections`.
+ */
+export interface RegistryServerConnection {
+ _id: string;
+ registryServerId: string;
+ workspaceId: string;
+ connectedAt: number;
+}
+
+export type RegistryConnectionStatus =
+ | "not_connected"
+ | "added"
+ | "connected"
+ | "connecting";
+
+export interface EnrichedRegistryServer extends RegistryServer {
+ connectionStatus: RegistryConnectionStatus;
+}
+
+/**
+ * Hook for fetching registry servers and managing connections.
+ *
+ * Pattern follows useWorkspaceMutations / useServerMutations in useWorkspaces.ts.
+ */
+export function useRegistryServers({
+ workspaceId,
+ isAuthenticated,
+ liveServers,
+ onConnect,
+ onDisconnect,
+}: {
+ workspaceId: string | null;
+ isAuthenticated: boolean;
+ /** Live MCP connection state from the app, keyed by server name */
+ liveServers?: Record;
+ onConnect: (formData: ServerFormData) => void;
+ onDisconnect?: (serverName: string) => void;
+}) {
+ // Fetch all approved registry servers (public — no auth required)
+ const remoteRegistryServers = useQuery(
+ "registryServers:listRegistryServers" as any,
+ DEV_MOCK_REGISTRY ? "skip" : ({} as any),
+ ) as RegistryServer[] | undefined;
+ const registryServers = DEV_MOCK_REGISTRY
+ ? MOCK_REGISTRY_SERVERS
+ : remoteRegistryServers;
+
+ // Fetch workspace-level connections
+ const connections = useQuery(
+ "registryServers:getWorkspaceRegistryConnections" as any,
+ !DEV_MOCK_REGISTRY && isAuthenticated && workspaceId
+ ? ({ workspaceId } as any)
+ : "skip",
+ ) as RegistryServerConnection[] | undefined;
+
+ const connectMutation = useMutation(
+ "registryServers:connectRegistryServer" as any,
+ );
+ const disconnectMutation = useMutation(
+ "registryServers:disconnectRegistryServer" as any,
+ );
+
+ // Set of registry server IDs that have a persistent connection in this workspace
+ const connectedRegistryIds = useMemo(() => {
+ if (!connections) return new Set();
+ return new Set(connections.map((c) => c.registryServerId));
+ }, [connections]);
+
+ // Enrich servers with connection status
+ const enrichedServers = useMemo(() => {
+ if (!registryServers) return [];
+
+ return registryServers.map((server) => {
+ const isAddedToWorkspace = connectedRegistryIds.has(server._id);
+ const liveServer = liveServers?.[server.displayName];
+ let connectionStatus: RegistryConnectionStatus = "not_connected";
+
+ if (liveServer?.connectionStatus === "connected") {
+ connectionStatus = "connected";
+ } else if (liveServer?.connectionStatus === "connecting") {
+ connectionStatus = "connecting";
+ } else if (isAddedToWorkspace) {
+ connectionStatus = "added";
+ }
+
+ return { ...server, connectionStatus };
+ });
+ }, [registryServers, connectedRegistryIds, liveServers]);
+
+ // Extract unique categories
+ const categories = useMemo(() => {
+ const cats = new Set();
+ for (const s of enrichedServers) {
+ if (s.category) cats.add(s.category);
+ }
+ return Array.from(cats).sort();
+ }, [enrichedServers]);
+
+ const isLoading = !DEV_MOCK_REGISTRY && registryServers === undefined;
+
+ async function connect(server: RegistryServer) {
+ // 1. Record the connection in Convex (only when authenticated with a workspace)
+ if (!DEV_MOCK_REGISTRY && isAuthenticated && workspaceId) {
+ await connectMutation({
+ registryServerId: server._id,
+ workspaceId,
+ } as any);
+ }
+
+ // 2. Trigger the local MCP connection
+ onConnect({
+ name: server.displayName,
+ type: "http",
+ url: server.transport.url,
+ useOAuth: server.transport.useOAuth,
+ oauthScopes: server.transport.oauthScopes,
+ clientId: server.transport.clientId,
+ registryServerId: server._id,
+ });
+ }
+
+ async function disconnect(server: RegistryServer) {
+ // 1. Remove the connection from Convex (only when authenticated with a workspace)
+ if (!DEV_MOCK_REGISTRY && isAuthenticated && workspaceId) {
+ await disconnectMutation({
+ registryServerId: server._id,
+ workspaceId,
+ } as any);
+ }
+
+ // 2. Trigger the local MCP disconnection
+ onDisconnect?.(server.displayName);
+ }
+
+ return {
+ registryServers: enrichedServers,
+ categories,
+ isLoading,
+ connect,
+ disconnect,
+ };
+}
diff --git a/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts b/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts
index 0cf3e6f6b..3665af38e 100644
--- a/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts
+++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-navigation.test.ts
@@ -6,7 +6,7 @@ import {
describe("hosted-navigation", () => {
it("normalizes hash aliases and strips hash prefix", () => {
- expect(getNormalizedHashParts("#registry")).toEqual(["servers"]);
+ expect(getNormalizedHashParts("#registry")).toEqual(["registry"]);
expect(getNormalizedHashParts("#/chat")).toEqual(["chat-v2"]);
expect(getNormalizedHashParts("prompts")).toEqual(["prompts"]);
});
@@ -37,7 +37,7 @@ describe("hosted-navigation", () => {
it("returns canonical section for hash synchronization", () => {
const resolved = resolveHostedNavigation("#/registry", true);
expect(resolved.rawSection).toBe("registry");
- expect(resolved.normalizedSection).toBe("servers");
+ expect(resolved.normalizedSection).toBe("registry");
});
it("allows ci-evals in hosted mode", () => {
diff --git a/mcpjam-inspector/client/src/lib/__tests__/hosted-tab-policy.test.ts b/mcpjam-inspector/client/src/lib/__tests__/hosted-tab-policy.test.ts
index 2c0560de0..00a369ab9 100644
--- a/mcpjam-inspector/client/src/lib/__tests__/hosted-tab-policy.test.ts
+++ b/mcpjam-inspector/client/src/lib/__tests__/hosted-tab-policy.test.ts
@@ -11,9 +11,10 @@ import {
describe("hosted-tab-policy", () => {
it("normalizes legacy hash aliases to canonical tabs", () => {
- expect(normalizeHostedHashTab("registry")).toBe("servers");
expect(normalizeHostedHashTab("chat")).toBe("chat-v2");
expect(normalizeHostedHashTab("chat-v2")).toBe("chat-v2");
+ // "registry" is now a first-class tab, not an alias
+ expect(normalizeHostedHashTab("registry")).toBe("registry");
});
it("keeps prompts visible in hosted sidebar allow-list", () => {
diff --git a/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts b/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts
index 5085bf428..2fc469deb 100644
--- a/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts
+++ b/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts
@@ -1,10 +1,10 @@
const HASH_TAB_ALIASES = {
- registry: "servers",
chat: "chat-v2",
} as const;
export const HOSTED_SIDEBAR_ALLOWED_TABS = [
"servers",
+ "registry",
"chat-v2",
"sandboxes",
"app-builder",
diff --git a/mcpjam-inspector/client/src/lib/oauth/__tests__/debug-oauth-client-metadata.test.ts b/mcpjam-inspector/client/src/lib/oauth/__tests__/debug-oauth-client-metadata.test.ts
index f307ac39b..deecdc98d 100644
--- a/mcpjam-inspector/client/src/lib/oauth/__tests__/debug-oauth-client-metadata.test.ts
+++ b/mcpjam-inspector/client/src/lib/oauth/__tests__/debug-oauth-client-metadata.test.ts
@@ -11,7 +11,10 @@ import {
createDebugOAuthStateMachine as createDebugOAuthStateMachine20251125,
EMPTY_OAUTH_FLOW_STATE_V2 as EMPTY_OAUTH_FLOW_STATE_20251125,
} from "../state-machines/debug-oauth-2025-11-25";
-import type { OAuthFlowState, OAuthStateMachine } from "../state-machines/types";
+import type {
+ OAuthFlowState,
+ OAuthStateMachine,
+} from "../state-machines/types";
const EXPECTED_LOGO_URI = "https://www.mcpjam.com/mcp_jam_2row.png";
const REDIRECT_URI = "https://app.mcpjam.com/oauth/callback/debug";
@@ -98,26 +101,29 @@ describe("OAuth debugger DCR client metadata", () => {
emptyState: EMPTY_OAUTH_FLOW_STATE_20251125,
expectedClientName: "MCPJam Inspector Debug Client",
},
- ])("%s includes logo_uri in the DCR registration payload", async (flowCase) => {
- vi.useFakeTimers();
+ ])(
+ "%s includes logo_uri in the DCR registration payload",
+ async (flowCase) => {
+ vi.useFakeTimers();
- const { machine, getState } = createStateMachineHarness(flowCase);
+ const { machine, getState } = createStateMachineHarness(flowCase);
- await machine.proceedToNextStep();
+ await machine.proceedToNextStep();
- expect(getState().currentStep).toBe("request_client_registration");
- expect(getState().lastRequest).toMatchObject({
- method: "POST",
- url: REGISTRATION_ENDPOINT,
- body: {
- client_name: flowCase.expectedClientName,
- logo_uri: EXPECTED_LOGO_URI,
- redirect_uris: [REDIRECT_URI],
- grant_types: ["authorization_code", "refresh_token"],
- response_types: ["code"],
- token_endpoint_auth_method: "none",
- scope: "read write",
- },
- });
- });
+ expect(getState().currentStep).toBe("request_client_registration");
+ expect(getState().lastRequest).toMatchObject({
+ method: "POST",
+ url: REGISTRATION_ENDPOINT,
+ body: {
+ client_name: flowCase.expectedClientName,
+ logo_uri: EXPECTED_LOGO_URI,
+ redirect_uris: [REDIRECT_URI],
+ grant_types: ["authorization_code", "refresh_token"],
+ response_types: ["code"],
+ token_endpoint_auth_method: "none",
+ scope: "read write",
+ },
+ });
+ },
+ );
});
diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts
index 2dd05d41c..72063bbc5 100644
--- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts
+++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts
@@ -15,6 +15,19 @@ import { HOSTED_MODE } from "@/lib/config";
// Store original fetch for restoration
const originalFetch = window.fetch;
+/**
+ * Derive the Convex HTTP actions URL (*.convex.site) from the Convex client URL.
+ */
+function getConvexSiteUrl(): string | null {
+ const siteUrl = (import.meta as any).env?.VITE_CONVEX_SITE_URL;
+ if (siteUrl) return siteUrl;
+ const cloudUrl = (import.meta as any).env?.VITE_CONVEX_URL;
+ if (cloudUrl && typeof cloudUrl === "string") {
+ return cloudUrl.replace(".convex.cloud", ".convex.site");
+ }
+ return null;
+}
+
interface StoredOAuthDiscoveryState {
serverUrl: string;
discoveryState: OAuthDiscoveryState;
@@ -29,9 +42,11 @@ function clearStoredDiscoveryState(serverName: string): void {
}
/**
- * Custom fetch interceptor that proxies OAuth requests through our server to avoid CORS
+ * Custom fetch interceptor that proxies OAuth requests through our server to avoid CORS.
+ * When a registryServerId is provided, token exchange/refresh is routed through
+ * the Convex HTTP registry OAuth endpoints which inject server-side secrets.
*/
-function createOAuthFetchInterceptor(): typeof fetch {
+function createOAuthFetchInterceptor(registryServerId?: string): typeof fetch {
return async function interceptedFetch(
input: RequestInfo | URL,
init?: RequestInit,
@@ -52,6 +67,33 @@ function createOAuthFetchInterceptor(): typeof fetch {
return await originalFetch(input, init);
}
+ // For registry servers, route token exchange/refresh through Convex HTTP actions
+ if (registryServerId) {
+ const isTokenRequest = url.match(/\/token$/);
+ if (isTokenRequest) {
+ const convexSiteUrl = getConvexSiteUrl();
+ if (convexSiteUrl) {
+ const body = init?.body ? await serializeBody(init.body) : undefined;
+ const isRefresh =
+ typeof body === "object" &&
+ body !== null &&
+ (body as any).grant_type === "refresh_token";
+ const endpoint = isRefresh
+ ? "/registry/oauth/refresh"
+ : "/registry/oauth/token";
+ const response = await originalFetch(`${convexSiteUrl}${endpoint}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ registryServerId,
+ ...(typeof body === "object" && body !== null ? body : {}),
+ }),
+ });
+ return response;
+ }
+ }
+ }
+
// Proxy OAuth requests through our server
try {
const isMetadata = url.includes("/.well-known/");
@@ -115,6 +157,8 @@ export interface MCPOAuthOptions {
scopes?: string[];
clientId?: string;
clientSecret?: string;
+ /** When set, uses Convex /registry/oauth/* routes instead of standard proxy */
+ registryServerId?: string;
}
export interface OAuthResult {
@@ -306,7 +350,9 @@ export async function initiateOAuth(
options: MCPOAuthOptions,
): Promise {
// Install fetch interceptor for OAuth metadata requests
- const interceptedFetch = createOAuthFetchInterceptor();
+ const interceptedFetch = createOAuthFetchInterceptor(
+ options.registryServerId,
+ );
window.fetch = interceptedFetch;
try {
@@ -324,11 +370,14 @@ export async function initiateOAuth(
);
localStorage.setItem("mcp-oauth-pending", options.serverName);
- // Store OAuth configuration (scopes) for recovery if connection fails
+ // Store OAuth configuration (scopes, registryServerId) for recovery if connection fails
const oauthConfig: any = {};
if (options.scopes && options.scopes.length > 0) {
oauthConfig.scopes = options.scopes;
}
+ if (options.registryServerId) {
+ oauthConfig.registryServerId = options.registryServerId;
+ }
localStorage.setItem(
`mcp-oauth-config-${options.serverName}`,
JSON.stringify(oauthConfig),
@@ -422,13 +471,22 @@ export async function initiateOAuth(
export async function handleOAuthCallback(
authorizationCode: string,
): Promise {
+ // Get pending server name from localStorage (needed before creating interceptor)
+ const serverName = localStorage.getItem("mcp-oauth-pending");
+
+ // Read registryServerId from stored OAuth config if present
+ const storedOAuthConfig = serverName
+ ? localStorage.getItem(`mcp-oauth-config-${serverName}`)
+ : null;
+ const registryServerId = storedOAuthConfig
+ ? JSON.parse(storedOAuthConfig).registryServerId
+ : undefined;
+
// Install fetch interceptor for OAuth metadata requests
- const interceptedFetch = createOAuthFetchInterceptor();
+ const interceptedFetch = createOAuthFetchInterceptor(registryServerId);
window.fetch = interceptedFetch;
try {
- // Get pending server name from localStorage
- const serverName = localStorage.getItem("mcp-oauth-pending");
if (!serverName) {
throw new Error("No pending OAuth flow found");
}
@@ -575,8 +633,16 @@ export async function waitForTokens(
export async function refreshOAuthTokens(
serverName: string,
): Promise {
+ // Read registryServerId from stored OAuth config if present
+ const storedOAuthConfig = localStorage.getItem(
+ `mcp-oauth-config-${serverName}`,
+ );
+ const registryServerId = storedOAuthConfig
+ ? JSON.parse(storedOAuthConfig).registryServerId
+ : undefined;
+
// Install fetch interceptor for OAuth metadata requests
- const interceptedFetch = createOAuthFetchInterceptor();
+ const interceptedFetch = createOAuthFetchInterceptor(registryServerId);
window.fetch = interceptedFetch;
try {
diff --git a/mcpjam-inspector/shared/types.ts b/mcpjam-inspector/shared/types.ts
index d8efed169..776bca398 100644
--- a/mcpjam-inspector/shared/types.ts
+++ b/mcpjam-inspector/shared/types.ts
@@ -598,6 +598,8 @@ export interface ServerFormData {
clientId?: string;
clientSecret?: string;
requestTimeout?: number;
+ /** Convex _id of the registry server (for OAuth routing via /registry/oauth/token) */
+ registryServerId?: string;
}
export interface OauthTokens {