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 ( + +
+
+
+ {`${server.name} { + e.currentTarget.style.display = "none"; + }} + /> +
+
+
+

+ {server.name} +

+ {isConnected && ( + + + Connected + + )} +
+

+ {server.description} +

+
+
+ +
+
{server.url}
+
+ +
+ {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 {