diff --git a/mcpjam-inspector/client/src/App.tsx b/mcpjam-inspector/client/src/App.tsx
index c715f27ca..d4e3770bb 100644
--- a/mcpjam-inspector/client/src/App.tsx
+++ b/mcpjam-inspector/client/src/App.tsx
@@ -19,6 +19,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";
@@ -500,6 +501,13 @@ export default function App() {
onLeaveWorkspace={() => handleLeaveWorkspace(activeWorkspaceId)}
/>
)}
+ {activeTab === "registry" && (
+
+ )}
{activeTab === "tools" && (
void;
+ onDisconnect?: (serverName: string) => void;
+ servers?: Record;
+}
+
+function RegistryServerCard({
+ server,
+ onConnect,
+ onDisconnect,
+ connectionStatus,
+}: {
+ server: RegistryServer;
+ onConnect: (formData: ServerFormData) => void;
+ onDisconnect?: (serverName: string) => void;
+ connectionStatus?: string;
+}) {
+ const [isConnecting, setIsConnecting] = useState(false);
+ const isConnected = connectionStatus === "connected";
+ const isInProgress =
+ connectionStatus === "connecting" || connectionStatus === "oauth-flow";
+
+ const handleConnect = () => {
+ setIsConnecting(true);
+ const formData: ServerFormData = {
+ name: server.name,
+ type: "http",
+ url: server.url,
+ useOAuth: server.useOAuth,
+ oauthScopes: server.oauthScopes,
+ clientId: server.clientId,
+ registryManaged: true,
+ registrySlug: server.slug,
+ };
+ onConnect(formData);
+ setTimeout(() => setIsConnecting(false), 2000);
+ };
+
+ const handleDisconnect = () => {
+ onDisconnect?.(server.name);
+ };
+
+ return (
+
+
+
+
+

{
+ e.currentTarget.style.display = "none";
+ }}
+ />
+
+
+
+
+ {server.name}
+
+ {isConnected && (
+
+
+ Connected
+
+ )}
+
+
+ {server.description}
+
+
+
+
+
+
+
+ {isConnected ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function LoadingSkeleton() {
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+export function RegistryTab({
+ onConnect,
+ onDisconnect,
+ servers,
+}: RegistryTabProps) {
+ const registryServers = useQuery(
+ "registryServers:listEnabled" as any,
+ {} as any,
+ ) as RegistryServer[] | undefined;
+
+ const isLoading = registryServers === undefined;
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!registryServers || registryServers.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ Server Registry
+
+
+ Connect to popular MCP servers with one click.
+
+
+
+ {registryServers.map((server) => {
+ const serverState = servers?.[server.name];
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx
index 49dff4880..6ec86a6d7 100644
--- a/mcpjam-inspector/client/src/components/mcp-sidebar.tsx
+++ b/mcpjam-inspector/client/src/components/mcp-sidebar.tsx
@@ -13,6 +13,7 @@ import {
ListTodo,
SquareSlash,
MessageCircleQuestionIcon,
+ Package,
} from "lucide-react";
import { usePostHog } from "posthog-js/react";
@@ -54,6 +55,11 @@ const navigationSections = [
url: "#servers",
icon: MCPIcon,
},
+ {
+ title: "Registry",
+ url: "#registry",
+ icon: Package,
+ },
{
title: "Chat",
url: "#chat-v2",
diff --git a/mcpjam-inspector/client/src/hooks/use-server-state.ts b/mcpjam-inspector/client/src/hooks/use-server-state.ts
index dbd6e1668..503711367 100644
--- a/mcpjam-inspector/client/src/hooks/use-server-state.ts
+++ b/mcpjam-inspector/client/src/hooks/use-server-state.ts
@@ -25,6 +25,7 @@ import {
getStoredTokens,
clearOAuthData,
initiateOAuth,
+ type MCPOAuthOptions,
} from "@/lib/oauth/mcp-oauth";
import { HOSTED_MODE } from "@/lib/config";
import { injectHostedServerMapping } from "@/lib/apis/web/context";
@@ -552,43 +553,49 @@ export function useServerState({
retryCount: 0,
enabled: true,
useOAuth: formData.useOAuth ?? false,
+ registryManaged: formData.registryManaged,
+ registrySlug: formData.registrySlug,
};
- if (HOSTED_MODE) {
- try {
- const serverId = await syncServerToConvex(
- formData.name,
- serverEntryForSave,
- );
- if (serverId) {
- injectHostedServerMapping(formData.name, serverId);
+
+ // Skip Convex workspace sync and workspace state for registry-managed servers
+ if (!formData.registryManaged) {
+ if (HOSTED_MODE) {
+ try {
+ const serverId = await syncServerToConvex(
+ formData.name,
+ serverEntryForSave,
+ );
+ if (serverId) {
+ injectHostedServerMapping(formData.name, serverId);
+ }
+ } catch (err) {
+ logger.warn("Sync to Convex failed (pre-connection)", {
+ serverName: formData.name,
+ err,
+ });
}
- } catch (err) {
- logger.warn("Sync to Convex failed (pre-connection)", {
- serverName: formData.name,
- err,
- });
+ } else {
+ syncServerToConvex(formData.name, serverEntryForSave).catch((err) =>
+ logger.warn("Background sync to Convex failed (pre-connection)", {
+ serverName: formData.name,
+ err,
+ }),
+ );
}
- } else {
- syncServerToConvex(formData.name, serverEntryForSave).catch((err) =>
- logger.warn("Background sync to Convex failed (pre-connection)", {
- serverName: formData.name,
- err,
- }),
- );
- }
- if (!isAuthenticated) {
- const workspace = appState.workspaces[appState.activeWorkspaceId];
- if (workspace) {
- dispatch({
- type: "UPDATE_WORKSPACE",
- workspaceId: appState.activeWorkspaceId,
- updates: {
- servers: {
- ...workspace.servers,
- [formData.name]: serverEntryForSave,
+ if (!isAuthenticated) {
+ const workspace = appState.workspaces[appState.activeWorkspaceId];
+ if (workspace) {
+ dispatch({
+ type: "UPDATE_WORKSPACE",
+ workspaceId: appState.activeWorkspaceId,
+ updates: {
+ servers: {
+ ...workspace.servers,
+ [formData.name]: serverEntryForSave,
+ },
},
- },
- });
+ });
+ }
}
}
@@ -654,15 +661,17 @@ export function useServerState({
} as ServerWithName,
});
- const oauthOptions: any = {
+ const oauthOptions: MCPOAuthOptions = {
serverName: formData.name,
serverUrl: formData.url,
clientId: formData.clientId,
- clientSecret: formData.clientSecret,
+ // For registry servers, clientSecret is injected server-side
+ clientSecret: formData.registryManaged
+ ? undefined
+ : formData.clientSecret,
+ registrySlug: formData.registrySlug,
+ scopes: formData.oauthScopes,
};
- if (formData.oauthScopes && formData.oauthScopes.length > 0) {
- oauthOptions.scopes = formData.oauthScopes;
- }
const oauthResult = await initiateOAuth(oauthOptions);
if (oauthResult.success) {
if (oauthResult.serverConfig) {
diff --git a/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts b/mcpjam-inspector/client/src/lib/hosted-tab-policy.ts
index 09ab7038b..91ae9992b 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",
"app-builder",
"views",
diff --git a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts
index bc3b62f2a..b3687be81 100644
--- a/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts
+++ b/mcpjam-inspector/client/src/lib/oauth/mcp-oauth.ts
@@ -17,7 +17,7 @@ const originalFetch = window.fetch;
/**
* Custom fetch interceptor that proxies OAuth requests through our server to avoid CORS
*/
-function createOAuthFetchInterceptor(): typeof fetch {
+function createOAuthFetchInterceptor(registrySlug?: string): typeof fetch {
return async function interceptedFetch(
input: RequestInfo | URL,
init?: RequestInit,
@@ -32,7 +32,7 @@ function createOAuthFetchInterceptor(): typeof fetch {
// 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|access_token|token|authorize)(?:[?#]|$)/);
if (!isOAuthRequest) {
return await originalFetch(input, init);
@@ -62,6 +62,7 @@ function createOAuthFetchInterceptor(): typeof fetch {
? Object.fromEntries(new Headers(init.headers as HeadersInit))
: {},
body,
+ ...(registrySlug && { registrySlug }),
}),
});
@@ -101,6 +102,7 @@ export interface MCPOAuthOptions {
scopes?: string[];
clientId?: string;
clientSecret?: string;
+ registrySlug?: string;
}
export interface OAuthResult {
@@ -247,14 +249,15 @@ export async function initiateOAuth(
options: MCPOAuthOptions,
): Promise {
// Install fetch interceptor for OAuth metadata requests
- const interceptedFetch = createOAuthFetchInterceptor();
+ const interceptedFetch = createOAuthFetchInterceptor(options.registrySlug);
window.fetch = interceptedFetch;
try {
const provider = new MCPOAuthProvider(
options.serverName,
options.clientId,
- options.clientSecret,
+ // For registry servers, the Hono server injects clientSecret during token exchange
+ options.registrySlug ? undefined : options.clientSecret,
);
// Store server URL for callback recovery
@@ -264,6 +267,14 @@ export async function initiateOAuth(
);
localStorage.setItem("mcp-oauth-pending", options.serverName);
+ // Store registrySlug for callback recovery (survives full-page redirect)
+ if (options.registrySlug) {
+ localStorage.setItem(
+ `mcp-registry-slug-${options.serverName}`,
+ options.registrySlug,
+ );
+ }
+
// Store OAuth configuration (scopes) for recovery if connection fails
const oauthConfig: any = {};
if (options.scopes && options.scopes.length > 0) {
@@ -362,17 +373,21 @@ export async function initiateOAuth(
export async function handleOAuthCallback(
authorizationCode: string,
): Promise {
+ // Get pending server name from localStorage (needed before interceptor setup)
+ const serverName = localStorage.getItem("mcp-oauth-pending");
+ if (!serverName) {
+ throw new Error("No pending OAuth flow found");
+ }
+
+ // Retrieve registrySlug stored before the redirect
+ const registrySlug =
+ localStorage.getItem(`mcp-registry-slug-${serverName}`) || undefined;
+
// Install fetch interceptor for OAuth metadata requests
- const interceptedFetch = createOAuthFetchInterceptor();
+ const interceptedFetch = createOAuthFetchInterceptor(registrySlug);
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");
- }
-
// Get server URL
const serverUrl = localStorage.getItem(`mcp-serverUrl-${serverName}`);
if (!serverUrl) {
@@ -384,9 +399,12 @@ export async function handleOAuthCallback(
const customClientId = storedClientInfo
? JSON.parse(storedClientInfo).client_id
: undefined;
- const customClientSecret = storedClientInfo
- ? JSON.parse(storedClientInfo).client_secret
- : undefined;
+ // For registry servers, don't use client_secret — Hono injects it server-side
+ const customClientSecret = registrySlug
+ ? undefined
+ : storedClientInfo
+ ? JSON.parse(storedClientInfo).client_secret
+ : undefined;
const provider = new MCPOAuthProvider(
serverName,
@@ -404,6 +422,7 @@ export async function handleOAuthCallback(
if (tokens) {
// Clean up pending state
localStorage.removeItem("mcp-oauth-pending");
+ localStorage.removeItem(`mcp-registry-slug-${serverName}`);
const serverConfig = createServerConfig(serverUrl, tokens);
return {
@@ -514,8 +533,12 @@ export async function waitForTokens(
export async function refreshOAuthTokens(
serverName: string,
): Promise {
+ // Retrieve registrySlug if this is a registry-managed server
+ const registrySlug =
+ localStorage.getItem(`mcp-registry-slug-${serverName}`) || undefined;
+
// Install fetch interceptor for OAuth metadata requests
- const interceptedFetch = createOAuthFetchInterceptor();
+ const interceptedFetch = createOAuthFetchInterceptor(registrySlug);
window.fetch = interceptedFetch;
try {
@@ -524,9 +547,12 @@ export async function refreshOAuthTokens(
const customClientId = storedClientInfo
? JSON.parse(storedClientInfo).client_id
: undefined;
- const customClientSecret = storedClientInfo
- ? JSON.parse(storedClientInfo).client_secret
- : undefined;
+ // For registry servers, don't use client_secret — Hono injects it server-side
+ const customClientSecret = registrySlug
+ ? undefined
+ : storedClientInfo
+ ? JSON.parse(storedClientInfo).client_secret
+ : undefined;
const provider = new MCPOAuthProvider(
serverName,
@@ -609,6 +635,7 @@ export function clearOAuthData(serverName: string): void {
localStorage.removeItem(`mcp-verifier-${serverName}`);
localStorage.removeItem(`mcp-serverUrl-${serverName}`);
localStorage.removeItem(`mcp-oauth-config-${serverName}`);
+ localStorage.removeItem(`mcp-registry-slug-${serverName}`);
}
/**
diff --git a/mcpjam-inspector/client/src/state/app-types.ts b/mcpjam-inspector/client/src/state/app-types.ts
index c2243996f..83600ce3d 100644
--- a/mcpjam-inspector/client/src/state/app-types.ts
+++ b/mcpjam-inspector/client/src/state/app-types.ts
@@ -41,6 +41,10 @@ export interface ServerWithName {
enabled?: boolean;
/** Whether OAuth is explicitly enabled for this server. When false, reconnect skips OAuth flow. */
useOAuth?: boolean;
+ /** Whether this server is managed by the Registry tab (not shown in Servers tab). */
+ registryManaged?: boolean;
+ /** Registry slug for credential injection during OAuth. */
+ registrySlug?: string;
}
export interface Workspace {
diff --git a/mcpjam-inspector/server/routes/mcp/oauth.ts b/mcpjam-inspector/server/routes/mcp/oauth.ts
index 1b50a01d9..e4cf72c4b 100644
--- a/mcpjam-inspector/server/routes/mcp/oauth.ts
+++ b/mcpjam-inspector/server/routes/mcp/oauth.ts
@@ -6,6 +6,8 @@ import {
executeDebugOAuthProxy,
fetchOAuthMetadata,
OAuthProxyError,
+ isTokenExchangeUrl,
+ fetchRegistryCredentials,
} from "../../utils/oauth-proxy.js";
const oauth = new Hono();
@@ -51,8 +53,23 @@ oauth.post("/debug/proxy", async (c) => {
*/
oauth.post("/proxy", async (c) => {
try {
- const { url, method, body, headers } = await c.req.json();
- const result = await executeOAuthProxy({ url, method, body, headers });
+ const { url, method, body, headers, registrySlug } = await c.req.json();
+
+ // For registry servers, inject clientSecret server-side during token exchange
+ let enrichedBody = body;
+ if (registrySlug && isTokenExchangeUrl(url)) {
+ const creds = await fetchRegistryCredentials(registrySlug);
+ if (creds?.clientSecret) {
+ enrichedBody = { ...body, client_secret: creds.clientSecret };
+ }
+ }
+
+ const result = await executeOAuthProxy({
+ url,
+ method,
+ body: enrichedBody,
+ headers,
+ });
return c.json(result);
} catch (error) {
if (error instanceof OAuthProxyError) {
diff --git a/mcpjam-inspector/server/utils/oauth-proxy.ts b/mcpjam-inspector/server/utils/oauth-proxy.ts
index 16bcb4ddc..7c68e1609 100644
--- a/mcpjam-inspector/server/utils/oauth-proxy.ts
+++ b/mcpjam-inspector/server/utils/oauth-proxy.ts
@@ -1,4 +1,5 @@
import type { ContentfulStatusCode } from "hono/utils/http-status";
+import { logger } from "./logger";
export class OAuthProxyError extends Error {
status: number;
@@ -301,3 +302,47 @@ export async function fetchOAuthMetadata(
const metadata = (await response.json()) as Record;
return { metadata };
}
+
+/**
+ * Check if a URL is a token exchange endpoint (where client_secret needs injection).
+ */
+export function isTokenExchangeUrl(url: string): boolean {
+ return /\/(token|access_token)(?:[?#]|$)/.test(url);
+}
+
+/**
+ * Fetch OAuth credentials for a registry server from Convex.
+ * Server-to-server call protected by a shared secret.
+ */
+export async function fetchRegistryCredentials(
+ slug: string,
+): Promise<{ clientId: string; clientSecret: string } | null> {
+ const convexUrl = process.env.CONVEX_HTTP_URL;
+ const secret = process.env.REGISTRY_SECRET;
+ if (!convexUrl || !secret) {
+ logger.warn(
+ "[Registry] Missing CONVEX_HTTP_URL or REGISTRY_SECRET for credential fetch",
+ );
+ return null;
+ }
+
+ try {
+ const res = await fetch(
+ `${convexUrl}/registry/credentials?slug=${encodeURIComponent(slug)}`,
+ {
+ headers: { "x-registry-secret": secret },
+ },
+ );
+ if (!res.ok) {
+ logger.warn("[Registry] Credential fetch failed", {
+ slug,
+ status: res.status,
+ });
+ return null;
+ }
+ return (await res.json()) as { clientId: string; clientSecret: string };
+ } catch (err) {
+ logger.error("[Registry] Credential fetch error", { slug, err });
+ return null;
+ }
+}
diff --git a/mcpjam-inspector/shared/types.ts b/mcpjam-inspector/shared/types.ts
index 6a73025c3..47963b9e7 100644
--- a/mcpjam-inspector/shared/types.ts
+++ b/mcpjam-inspector/shared/types.ts
@@ -585,6 +585,8 @@ export interface ServerFormData {
clientId?: string;
clientSecret?: string;
requestTimeout?: number;
+ registryManaged?: boolean;
+ registrySlug?: string;
}
export interface OauthTokens {