@@ -56,11 +56,14 @@ const StatusMessage = ({ message }: StatusMessageProps) => {
};
const AuthDebugger = ({
- serverUrl: serverUrl,
+ serverUrl,
onBack,
authState,
updateAuthState,
}: AuthDebuggerProps) => {
+ const [flowMode, setFlowMode] = useState<"debug" | "quick" | null>(null);
+ const [flowComplete, setFlowComplete] = useState(false);
+
// Check for existing tokens on mount
useEffect(() => {
if (serverUrl && !authState.oauthTokens) {
@@ -82,139 +85,69 @@ const AuthDebugger = ({
}
}, [serverUrl, updateAuthState, authState.oauthTokens]);
- const startOAuthFlow = useCallback(() => {
- if (!serverUrl) {
- updateAuthState({
- statusMessage: {
- type: "error",
- message:
- "Please enter a server URL in the sidebar before authenticating",
- },
- });
- return;
- }
-
- updateAuthState({
- oauthStep: "metadata_discovery",
- authorizationUrl: null,
- statusMessage: null,
- latestError: null,
- });
- }, [serverUrl, updateAuthState]);
-
- const stateMachine = useMemo(
- () => new OAuthStateMachine(serverUrl, updateAuthState),
- [serverUrl, updateAuthState],
- );
-
- const proceedToNextStep = useCallback(async () => {
- if (!serverUrl) return;
+ const startFlow = useCallback(
+ (mode: "debug" | "quick") => {
+ if (!serverUrl) {
+ updateAuthState({
+ statusMessage: {
+ type: "error",
+ message:
+ "Please enter a server URL in the sidebar before authenticating",
+ },
+ });
+ return;
+ }
- try {
updateAuthState({
- isInitiatingAuth: true,
statusMessage: null,
latestError: null,
});
+ setFlowComplete(false);
+ setFlowMode(mode);
+ },
+ [serverUrl, updateAuthState],
+ );
- await stateMachine.executeStep(authState);
- } catch (error) {
- console.error("OAuth flow error:", error);
- updateAuthState({
- latestError: error instanceof Error ? error : new Error(String(error)),
- });
- } finally {
- updateAuthState({ isInitiatingAuth: false });
- }
- }, [serverUrl, authState, updateAuthState, stateMachine]);
+ const startRunFlow = useCallback(() => startFlow("quick"), [startFlow]);
+ const startSlowFlow = useCallback(() => startFlow("debug"), [startFlow]);
- const handleQuickOAuth = useCallback(async () => {
- if (!serverUrl) {
+ const handleFlowComplete = useCallback(
+ (tokens: OAuthTokens) => {
updateAuthState({
- statusMessage: {
- type: "error",
- message:
- "Please enter a server URL in the sidebar before authenticating",
- },
+ oauthTokens: tokens,
+ oauthStep: "complete",
});
- return;
- }
-
- updateAuthState({ isInitiatingAuth: true, statusMessage: null });
- try {
- // Step through the OAuth flow using the state machine instead of the auth() function
- let currentState: AuthDebuggerState = {
- ...authState,
- oauthStep: "metadata_discovery",
- authorizationUrl: null,
- latestError: null,
- };
-
- const oauthMachine = new OAuthStateMachine(serverUrl, (updates) => {
- // Update our temporary state during the process
- currentState = { ...currentState, ...updates };
- // But don't call updateAuthState yet
- });
-
- // Manually step through each stage of the OAuth flow
- while (currentState.oauthStep !== "complete") {
- await oauthMachine.executeStep(currentState);
- // In quick mode, we'll just redirect to the authorization URL
- if (
- currentState.oauthStep === "authorization_code" &&
- currentState.authorizationUrl
- ) {
- // Validate the URL before redirecting
- try {
- validateRedirectUrl(currentState.authorizationUrl);
- } catch (error) {
- updateAuthState({
- ...currentState,
- isInitiatingAuth: false,
- latestError:
- error instanceof Error ? error : new Error(String(error)),
- statusMessage: {
- type: "error",
- message: `Invalid authorization URL: ${error instanceof Error ? error.message : String(error)}`,
- },
- });
- return;
- }
+ // Keep the flow visible but mark as complete
+ setFlowComplete(true);
+ },
+ [updateAuthState],
+ );
- // Store the current auth state before redirecting
- sessionStorage.setItem(
- SESSION_KEYS.AUTH_DEBUGGER_STATE,
- JSON.stringify(currentState),
- );
- // Open the authorization URL automatically
- window.location.href = currentState.authorizationUrl.toString();
- break;
- }
- }
+ const handleFlowCancel = useCallback(() => {
+ setFlowMode(null);
+ setFlowComplete(false);
+ updateAuthState({
+ statusMessage: {
+ type: "info",
+ message: "OAuth flow cancelled",
+ },
+ });
+ }, [updateAuthState]);
- // After the flow completes or reaches a user-input step, update the app state
- updateAuthState({
- ...currentState,
- statusMessage: {
- type: "info",
- message:
- currentState.oauthStep === "complete"
- ? "Authentication completed successfully"
- : "Please complete authentication in the opened window and enter the code",
- },
- });
- } catch (error) {
- console.error("OAuth initialization error:", error);
+ const handleFlowError = useCallback(
+ (error: Error) => {
+ setFlowMode(null);
+ setFlowComplete(false);
updateAuthState({
+ latestError: error,
statusMessage: {
type: "error",
- message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`,
+ message: `OAuth flow failed: ${error.message}`,
},
});
- } finally {
- updateAuthState({ isInitiatingAuth: false });
- }
- }, [serverUrl, updateAuthState, authState]);
+ },
+ [updateAuthState],
+ );
const handleClearOAuth = useCallback(() => {
if (serverUrl) {
@@ -229,6 +162,8 @@ const AuthDebugger = ({
message: "OAuth tokens cleared successfully",
},
});
+ setFlowMode(null);
+ setFlowComplete(false);
// Clear success message after 3 seconds
setTimeout(() => {
@@ -237,6 +172,11 @@ const AuthDebugger = ({
}
}, [serverUrl, updateAuthState]);
+ const handleNewFlow = useCallback(() => {
+ setFlowMode(null);
+ setFlowComplete(false);
+ }, []);
+
return (
@@ -273,46 +213,50 @@ const AuthDebugger = ({
)}
-
+
+
+ {flowComplete && (
+
+ )}
- Choose "Guided" for step-by-step instructions or "Quick" for
- the standard automatic flow.
+ Run Flow: Run the entire OAuth flow
+ automatically. Slow Mo: Step through each
+ request one at a time.
-
+ {flowMode && (
+
+ )}
diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx
new file mode 100644
index 000000000..f3562926b
--- /dev/null
+++ b/client/src/components/AuthDebuggerFlow.tsx
@@ -0,0 +1,797 @@
+/**
+ * OAuth debug flow component that shows each HTTP request individually
+ * with pause/continue functionality.
+ */
+
+import { useState, useCallback, useEffect, useRef } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ Circle,
+ ExternalLink,
+ Info,
+ Loader2,
+} from "lucide-react";
+import { createDebugFetch, DebugRequestResponse } from "@/lib/debug-middleware";
+import { DebugOAuthProvider } from "@/lib/DebugOAuthProvider";
+import {
+ auth,
+ exchangeAuthorization,
+ extractWWWAuthenticateParams,
+} from "@modelcontextprotocol/sdk/client/auth.js";
+import {
+ OAuthTokens,
+ OAuthMetadata,
+} from "@modelcontextprotocol/sdk/shared/auth.js";
+import { validateRedirectUrl } from "@/utils/urlValidation";
+import { useToast } from "@/lib/hooks/useToast";
+
+interface AuthDebuggerFlowProps {
+ serverUrl: string;
+ quickMode?: boolean;
+ onComplete: (tokens: OAuthTokens) => void;
+ onCancel: () => void;
+ onError: (error: Error) => void;
+}
+
+type FlowState =
+ | "running"
+ | "waiting_continue"
+ | "waiting_code"
+ | "complete"
+ | "error";
+
+export function AuthDebuggerFlow({
+ serverUrl,
+ quickMode = false,
+ onComplete,
+ onCancel,
+ onError,
+}: AuthDebuggerFlowProps) {
+ const { toast } = useToast();
+ const [completedSteps, setCompletedSteps] = useState(
+ [],
+ );
+ const [currentStep, setCurrentStep] = useState(
+ null,
+ );
+ const [flowState, setFlowState] = useState("running");
+ const [authUrl, setAuthUrl] = useState(null);
+ const [authCode, setAuthCode] = useState("");
+
+ // Use refs to store resolvers so they persist across renders
+ const continueResolverRef = useRef<(() => void) | null>(null);
+ const authCodeResolverRef = useRef<((code: string) => void) | null>(null);
+ const flowStartedRef = useRef(false);
+ const popupRef = useRef(null);
+
+ // Cache discovered metadata to avoid re-fetching during token exchange
+ // TODO: The SDK's auth() function should accept pre-fetched metadata to avoid
+ // redundant discovery requests. For now, we capture it from responses and use
+ // exchangeAuthorization() directly for the token exchange step.
+ const cachedMetadataRef = useRef<{
+ authServerMetadata?: OAuthMetadata;
+ authServerUrl?: string;
+ resource?: URL;
+ }>({});
+
+ // Track discovery state to inject informational warnings
+ const discoveryStateRef = useRef<{
+ prmFailed: boolean;
+ shownPrmWarning: boolean;
+ oauthMetadataSuccess: boolean;
+ shownNoMetadataWarning: boolean;
+ }>({
+ prmFailed: false,
+ shownPrmWarning: false,
+ oauthMetadataSuccess: false,
+ shownNoMetadataWarning: false,
+ });
+
+ // Handler for middleware callback - pauses until Continue clicked (unless quickMode)
+ const handleRequestComplete = useCallback(
+ async (entry: DebugRequestResponse) => {
+ if (quickMode) {
+ // Quick mode: add directly to completed, don't pause
+ setCompletedSteps((prev) => [...prev, entry]);
+ return;
+ }
+
+ // Debug mode: show as current step, wait for Continue, then add to completed
+ setCurrentStep(entry);
+ setFlowState("waiting_continue");
+ await new Promise((resolve) => {
+ continueResolverRef.current = resolve;
+ });
+ setCompletedSteps((prev) => [...prev, entry]);
+ setCurrentStep(null);
+ setFlowState("running");
+ },
+ [quickMode],
+ );
+
+ // Handler for auth URL - pauses until code entered
+ const handleAwaitAuthCode = useCallback(async (url: URL): Promise => {
+ setAuthUrl(url);
+ setFlowState("waiting_code");
+ return new Promise((resolve) => {
+ authCodeResolverRef.current = resolve;
+ });
+ }, []);
+
+ const handleContinue = useCallback(() => {
+ if (continueResolverRef.current) {
+ continueResolverRef.current();
+ continueResolverRef.current = null;
+ }
+ }, []);
+
+ const handleSubmitCode = useCallback(() => {
+ if (authCodeResolverRef.current && authCode.trim()) {
+ authCodeResolverRef.current(authCode.trim());
+ authCodeResolverRef.current = null;
+ setAuthUrl(null);
+ setAuthCode("");
+ setFlowState("running");
+ }
+ }, [authCode]);
+
+ // Start the flow
+ useEffect(() => {
+ if (flowStartedRef.current) return;
+ flowStartedRef.current = true;
+
+ // Helper to create error steps
+ function createErrorStep(
+ label: string,
+ message: React.ReactNode,
+ ): DebugRequestResponse {
+ return {
+ id: crypto.randomUUID(),
+ label,
+ request: { method: "ERROR", url: "", headers: {} },
+ response: {
+ status: 0,
+ statusText: "Error",
+ headers: {},
+ body: { message },
+ },
+ };
+ }
+
+ async function runDebugFlow() {
+ const baseDebugFetch = createDebugFetch(handleRequestComplete);
+
+ // Wrap debugFetch to capture metadata and inject info/warning steps
+ const debugFetch: typeof fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input.toString();
+ const method = init?.method || "GET";
+
+ // Before OAuth metadata request - inject PRM warning if needed
+ if (
+ (url.includes(".well-known/oauth-authorization-server") ||
+ url.includes(".well-known/openid-configuration")) &&
+ discoveryStateRef.current.prmFailed &&
+ !discoveryStateRef.current.shownPrmWarning
+ ) {
+ const errorEntry = createErrorStep(
+ "Error: No PRM Found",
+ <>
+ Server does not have Protected Resource Metadata. Falling back to{" "}
+
+ 2025-03-26 spec
+ {" "}
+ authorization base URL discovery. This often is due to an
+ incorrect server URL preventing PRM discovery. Please double-check
+ the server URL is correct.
+ >,
+ );
+ await handleRequestComplete(errorEntry);
+ discoveryStateRef.current.shownPrmWarning = true;
+ }
+
+ // Before POST /register - inject warning if no metadata found
+ if (
+ method === "POST" &&
+ url.includes("/register") &&
+ !discoveryStateRef.current.oauthMetadataSuccess &&
+ !discoveryStateRef.current.shownNoMetadataWarning
+ ) {
+ const errorEntry = createErrorStep(
+ "Error: No Metadata Found",
+ <>
+ Failed to discover OAuth authorization server metadata. Falling
+ back to{" "}
+
+ 2025-03-26 spec
+ {" "}
+ server-without-metadata mode. This is unlikely to work, and is
+ often due to an incorrect server URL. Please check the MCP URL you
+ entered is correct.
+ >,
+ );
+ await handleRequestComplete(errorEntry);
+ discoveryStateRef.current.shownNoMetadataWarning = true;
+ }
+
+ // Make the actual request
+ let response: Response;
+ try {
+ response = await baseDebugFetch(input, init);
+ } catch (error) {
+ // Track failures even when fetch throws (CORS, network errors)
+ if (url.includes(".well-known/oauth-protected-resource")) {
+ discoveryStateRef.current.prmFailed = true;
+ }
+ throw error;
+ }
+
+ // Track PRM discovery
+ if (url.includes(".well-known/oauth-protected-resource")) {
+ if (!response.ok) {
+ discoveryStateRef.current.prmFailed = true;
+ }
+ }
+
+ // Capture and track OAuth metadata
+ if (
+ url.includes(".well-known/oauth-authorization-server") ||
+ url.includes(".well-known/openid-configuration")
+ ) {
+ if (response.ok) {
+ discoveryStateRef.current.oauthMetadataSuccess = true;
+ try {
+ const cloned = response.clone();
+ const metadata = await cloned.json();
+ cachedMetadataRef.current.authServerMetadata = metadata;
+ const authServerUrl = new URL(url);
+ cachedMetadataRef.current.authServerUrl = authServerUrl.origin;
+ } catch {
+ // Ignore parse errors
+ }
+ }
+ }
+
+ return response;
+ };
+
+ const provider = new DebugOAuthProvider(serverUrl);
+ provider.setAuthCodeHandler(handleAwaitAuthCode);
+
+ try {
+ // Step 1: Try to initialize to get 401 with WWW-Authenticate header
+ let resourceMetadataUrl: URL | undefined;
+ let scope: string | undefined;
+
+ try {
+ const initResponse = await debugFetch(serverUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ jsonrpc: "2.0",
+ method: "initialize",
+ params: {
+ protocolVersion: "2025-03-26",
+ capabilities: {},
+ clientInfo: { name: "mcp-inspector", version: "1.0.0" },
+ },
+ id: 1,
+ }),
+ });
+
+ if (initResponse.status === 401) {
+ const params = extractWWWAuthenticateParams(initResponse);
+ resourceMetadataUrl = params.resourceMetadataUrl;
+ scope = params.scope;
+ } else if (initResponse.ok) {
+ onError(
+ new Error(
+ `Server returned ${initResponse.status} - authentication may not be required`,
+ ),
+ );
+ return;
+ }
+ // For other non-401 errors, we'll continue without the metadata
+ } catch (fetchError) {
+ // Network errors (CORS preflight failure, connection refused, etc.)
+ // Record this as a failed step but continue with discovery
+ const errorMessage =
+ fetchError instanceof TypeError
+ ? "CORS preflight failed - server may not allow cross-origin requests"
+ : fetchError instanceof Error
+ ? fetchError.message
+ : String(fetchError);
+
+ // Add a "failed" step to show what happened
+ const failedEntry: DebugRequestResponse = {
+ id: crypto.randomUUID(),
+ label: `POST ${new URL(serverUrl).pathname || "/"}`,
+ request: {
+ method: "POST",
+ url: serverUrl,
+ headers: { "Content-Type": "application/json" },
+ body: {
+ jsonrpc: "2.0",
+ method: "initialize",
+ params: {
+ protocolVersion: "2025-03-26",
+ capabilities: {},
+ clientInfo: { name: "mcp-inspector", version: "1.0.0" },
+ },
+ id: 1,
+ },
+ },
+ response: {
+ status: 0,
+ statusText: "Failed",
+ headers: {},
+ body: {
+ error: errorMessage,
+ note: "Continuing with OAuth discovery without WWW-Authenticate metadata",
+ },
+ },
+ };
+
+ if (quickMode) {
+ // Quick mode: add directly to completed
+ setCompletedSteps((prev) => [...prev, failedEntry]);
+ } else {
+ // Debug mode: show as current step, wait for continue, then add to completed
+ setCurrentStep(failedEntry);
+ setFlowState("waiting_continue");
+ await new Promise((resolve) => {
+ continueResolverRef.current = resolve;
+ });
+ setCompletedSteps((prev) => [...prev, failedEntry]);
+ setCurrentStep(null);
+ setFlowState("running");
+ }
+ }
+
+ // Step 2: Run auth() for discovery, registration, and authorization start
+ // The debugFetch wrapper captures auth server metadata for later use
+ const result = await auth(provider, {
+ serverUrl,
+ resourceMetadataUrl,
+ scope,
+ fetchFn: debugFetch,
+ });
+
+ // Step 3: If REDIRECT, use exchangeAuthorization directly with cached metadata
+ // This avoids the redundant metadata discovery that auth() would do
+ if (result === "REDIRECT") {
+ const authorizationCode = provider.getPendingAuthCode();
+ if (!authorizationCode) {
+ onError(new Error("No authorization code received"));
+ return;
+ }
+
+ provider.clearPendingAuthCode();
+
+ const clientInfo = await provider.clientInformation();
+ if (!clientInfo) {
+ onError(new Error("No client information available"));
+ return;
+ }
+
+ const codeVerifier = provider.codeVerifier();
+ const { authServerMetadata, authServerUrl } =
+ cachedMetadataRef.current;
+
+ if (!authServerUrl) {
+ onError(new Error("No auth server URL cached"));
+ return;
+ }
+
+ // Use exchangeAuthorization directly instead of auth()
+ const tokens = await exchangeAuthorization(authServerUrl, {
+ metadata: authServerMetadata,
+ clientInformation: clientInfo,
+ authorizationCode,
+ codeVerifier,
+ redirectUri: provider.redirectUrl,
+ resource: cachedMetadataRef.current.resource,
+ fetchFn: debugFetch,
+ });
+
+ provider.saveTokens(tokens);
+ setFlowState("complete");
+ onComplete(tokens);
+ return;
+ }
+
+ if (result === "AUTHORIZED") {
+ const tokens = await provider.tokens();
+ if (tokens) {
+ setFlowState("complete");
+ onComplete(tokens);
+ } else {
+ onError(new Error("No tokens received after authorization"));
+ }
+ }
+ } catch (error) {
+ console.error("OAuth debug flow error:", error);
+
+ // Show the final error as a step instead of dismissing the flow
+ const errorMessage =
+ error instanceof Error ? error.message : String(error);
+ const errorEntry = createErrorStep(
+ "Flow Error",
+ <>
+ {errorMessage}
+
+
+ The OAuth flow could not complete. This may be due to CORS
+ restrictions or server configuration.
+
+ >,
+ );
+
+ if (quickMode) {
+ setCompletedSteps((prev) => [...prev, errorEntry]);
+ setFlowState("error");
+ } else {
+ setCurrentStep(errorEntry);
+ setFlowState("waiting_continue");
+ await new Promise((resolve) => {
+ continueResolverRef.current = resolve;
+ });
+ setCompletedSteps((prev) => [...prev, errorEntry]);
+ setCurrentStep(null);
+ setFlowState("error");
+ }
+ }
+ }
+
+ runDebugFlow();
+ }, [
+ serverUrl,
+ handleRequestComplete,
+ handleAwaitAuthCode,
+ onComplete,
+ onError,
+ ]);
+
+ // Listen for postMessage from popup (opener pattern)
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ // Only accept messages from same origin
+ if (event.origin !== window.location.origin) return;
+ if (event.data?.type === "oauth-callback" && event.data?.code) {
+ // Auto-fill the auth code from popup
+ if (authCodeResolverRef.current) {
+ authCodeResolverRef.current(event.data.code);
+ authCodeResolverRef.current = null;
+ setAuthUrl(null);
+ setAuthCode("");
+ setFlowState("running");
+ }
+ // Close popup if we opened it
+ popupRef.current?.close();
+ popupRef.current = null;
+ }
+ };
+
+ window.addEventListener("message", handleMessage);
+ return () => window.removeEventListener("message", handleMessage);
+ }, []);
+
+ const handleOpenAuthUrl = () => {
+ if (authUrl) {
+ try {
+ validateRedirectUrl(authUrl.href);
+ // Open as popup and keep reference for message receiving
+ popupRef.current = window.open(
+ authUrl.href,
+ "oauth-popup",
+ "width=600,height=700",
+ );
+ } catch (error) {
+ toast({
+ title: "Invalid URL",
+ description:
+ error instanceof Error
+ ? error.message
+ : "The authorization URL is not valid",
+ variant: "destructive",
+ });
+ }
+ }
+ };
+
+ return (
+
+
+
OAuth Debug Flow
+
+
+
+
+ Step through the OAuth flow one request at a time.
+
- Resource metadata was added in the{" "}
-
- 2025-06-18 specification update
-
-
- {authState.resourceMetadataError.message}
- {authState.resourceMetadataError instanceof TypeError &&
- " (This could indicate the endpoint doesn't exist or does not have CORS configured)"}
-