From 79376c846f03123375d15d4416784c958e1c225b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 18:43:59 +0000 Subject: [PATCH 01/16] refactor(auth-debugger): use middleware-based request-level pausing This refactors the inspector's auth debugger to use the typescript-sdk middleware pattern, showing each HTTP request individually with pause/continue functionality. Key changes: - New debug-middleware.ts: Creates a fetch wrapper that captures request/response pairs and pauses after each request completes - New DebugOAuthProvider.ts: OAuth provider that intercepts authorization redirects and allows manual code entry instead of redirecting - New AuthDebuggerFlow.tsx: Component that orchestrates the debug flow, showing each request step with Continue buttons - Simplified AuthDebugger.tsx: Removed quick flow, integrated new debug flow component - Removed oauth-state-machine.ts: No longer needed, replaced by SDK's auth() function with middleware - Removed OAuthFlowProgress.tsx: Replaced by AuthDebuggerFlow The new approach: 1. Makes an initialize POST to trigger 401 2. Calls SDK's auth() function with debug middleware 3. Middleware captures each request/response and pauses for user to click Continue 4. When authorization is needed, shows URL for manual copy and code input field 5. Completes token exchange through middleware This provides better visibility into each OAuth request while leveraging the SDK's auth orchestration. --- client/src/App.tsx | 64 +- client/src/__tests__/App.config.test.tsx | 4 - client/src/__tests__/App.routing.test.tsx | 4 - .../__tests__/App.samplingNavigation.test.tsx | 4 - client/src/components/AuthDebugger.tsx | 192 ++---- client/src/components/AuthDebuggerFlow.tsx | 419 ++++++++++++ client/src/components/OAuthFlowProgress.tsx | 398 ----------- .../__tests__/AuthDebugger.test.tsx | 636 +++--------------- client/src/lib/DebugOAuthProvider.ts | 185 +++++ client/src/lib/debug-middleware.ts | 170 +++++ client/src/lib/oauth-state-machine.ts | 232 ------- package-lock.json | 107 ++- 12 files changed, 1018 insertions(+), 1397 deletions(-) create mode 100644 client/src/components/AuthDebuggerFlow.tsx delete mode 100644 client/src/components/OAuthFlowProgress.tsx create mode 100644 client/src/lib/DebugOAuthProvider.ts create mode 100644 client/src/lib/debug-middleware.ts delete mode 100644 client/src/lib/oauth-state-machine.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 39fc2812a..5bee07b79 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -31,7 +31,6 @@ import { isReservedMetaKey, } from "@/utils/metaUtils"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "./lib/auth-types"; -import { OAuthStateMachine } from "./lib/oauth-state-machine"; import { cacheToolOutputSchemas } from "./utils/schemaUtils"; import { cleanParams } from "./utils/paramUtils"; import type { JsonSchemaType } from "./utils/jsonUtils"; @@ -552,10 +551,9 @@ const App = () => { ); const onOAuthDebugConnect = useCallback( - async ({ + ({ authorizationCode, errorMsg, - restoredState, }: { authorizationCode?: string; errorMsg?: string; @@ -566,62 +564,26 @@ const App = () => { if (errorMsg) { updateAuthState({ latestError: new Error(errorMsg), + statusMessage: { + type: "error", + message: errorMsg, + }, }); return; } - if (restoredState && authorizationCode) { - let currentState: AuthDebuggerState = { - ...restoredState, - authorizationCode, - oauthStep: "token_request", - isInitiatingAuth: true, - statusMessage: null, - latestError: null, - }; - - try { - const stateMachine = new OAuthStateMachine(sseUrl, (updates) => { - currentState = { ...currentState, ...updates }; - }); - - while ( - currentState.oauthStep !== "complete" && - currentState.oauthStep !== "authorization_code" - ) { - await stateMachine.executeStep(currentState); - } - - if (currentState.oauthStep === "complete") { - updateAuthState({ - ...currentState, - statusMessage: { - type: "success", - message: "Authentication completed successfully", - }, - isInitiatingAuth: false, - }); - } - } catch (error) { - console.error("OAuth continuation error:", error); - updateAuthState({ - latestError: - error instanceof Error ? error : new Error(String(error)), - statusMessage: { - type: "error", - message: `Failed to complete OAuth flow: ${error instanceof Error ? error.message : String(error)}`, - }, - isInitiatingAuth: false, - }); - } - } else if (authorizationCode) { + if (authorizationCode) { + // Show info message - the user should use the debug flow to complete updateAuthState({ - authorizationCode, - oauthStep: "token_request", + statusMessage: { + type: "info", + message: + "Authorization code received. Use the debug flow to complete authentication.", + }, }); } }, - [sseUrl], + [], ); useEffect(() => { diff --git a/client/src/__tests__/App.config.test.tsx b/client/src/__tests__/App.config.test.tsx index 7458c2055..faf0ac823 100644 --- a/client/src/__tests__/App.config.test.tsx +++ b/client/src/__tests__/App.config.test.tsx @@ -9,10 +9,6 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), })); -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - jest.mock("../lib/auth", () => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockResolvedValue(null), diff --git a/client/src/__tests__/App.routing.test.tsx b/client/src/__tests__/App.routing.test.tsx index 4713bef9a..cf3b38aac 100644 --- a/client/src/__tests__/App.routing.test.tsx +++ b/client/src/__tests__/App.routing.test.tsx @@ -8,10 +8,6 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), })); -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - jest.mock("../lib/auth", () => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockResolvedValue(null), diff --git a/client/src/__tests__/App.samplingNavigation.test.tsx b/client/src/__tests__/App.samplingNavigation.test.tsx index 70a42a92f..d10d1b7c3 100644 --- a/client/src/__tests__/App.samplingNavigation.test.tsx +++ b/client/src/__tests__/App.samplingNavigation.test.tsx @@ -32,10 +32,6 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), })); -jest.mock("../lib/oauth-state-machine", () => ({ - OAuthStateMachine: jest.fn(), -})); - jest.mock("../lib/auth", () => ({ InspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockResolvedValue(null), diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 6252c1161..cc02682c1 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,12 +1,10 @@ -import { useCallback, useMemo, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { DebugInspectorOAuthClientProvider } from "../lib/auth"; -import { AlertCircle } from "lucide-react"; +import { AlertCircle, CheckCircle2 } from "lucide-react"; import { AuthDebuggerState, EMPTY_DEBUGGER_STATE } from "../lib/auth-types"; -import { OAuthFlowProgress } from "./OAuthFlowProgress"; -import { OAuthStateMachine } from "../lib/oauth-state-machine"; -import { SESSION_KEYS } from "../lib/constants"; -import { validateRedirectUrl } from "@/utils/urlValidation"; +import { AuthDebuggerFlow } from "./AuthDebuggerFlow"; +import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; export interface AuthDebuggerProps { serverUrl: string; @@ -23,6 +21,7 @@ const StatusMessage = ({ message }: StatusMessageProps) => { let bgColor: string; let textColor: string; let borderColor: string; + let Icon = AlertCircle; switch (message.type) { case "error": @@ -34,6 +33,7 @@ const StatusMessage = ({ message }: StatusMessageProps) => { bgColor = "bg-green-50"; textColor = "text-green-700"; borderColor = "border-green-200"; + Icon = CheckCircle2; break; case "info": default: @@ -48,7 +48,7 @@ const StatusMessage = ({ message }: StatusMessageProps) => { className={`p-3 rounded-md border ${bgColor} ${borderColor} ${textColor} mb-4`} >
- +

{message.message}

@@ -56,11 +56,13 @@ const StatusMessage = ({ message }: StatusMessageProps) => { }; const AuthDebugger = ({ - serverUrl: serverUrl, + serverUrl, onBack, authState, updateAuthState, }: AuthDebuggerProps) => { + const [showDebugFlow, setShowDebugFlow] = useState(false); + // Check for existing tokens on mount useEffect(() => { if (serverUrl && !authState.oauthTokens) { @@ -82,7 +84,7 @@ const AuthDebugger = ({ } }, [serverUrl, updateAuthState, authState.oauthTokens]); - const startOAuthFlow = useCallback(() => { + const startDebugFlow = useCallback(() => { if (!serverUrl) { updateAuthState({ statusMessage: { @@ -95,126 +97,50 @@ const AuthDebugger = ({ } updateAuthState({ - oauthStep: "metadata_discovery", - authorizationUrl: null, statusMessage: null, latestError: null, }); + setShowDebugFlow(true); }, [serverUrl, updateAuthState]); - const stateMachine = useMemo( - () => new OAuthStateMachine(serverUrl, updateAuthState), - [serverUrl, updateAuthState], - ); - - const proceedToNextStep = useCallback(async () => { - if (!serverUrl) return; - - try { - updateAuthState({ - isInitiatingAuth: true, - statusMessage: null, - latestError: null, - }); - - 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 handleQuickOAuth = useCallback(async () => { - if (!serverUrl) { + const handleFlowComplete = useCallback( + (tokens: OAuthTokens) => { updateAuthState({ + oauthTokens: tokens, + oauthStep: "complete", statusMessage: { - type: "error", - message: - "Please enter a server URL in the sidebar before authenticating", + type: "success", + message: "Authentication completed successfully", }, }); - 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; - } + setShowDebugFlow(false); + }, + [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(() => { + setShowDebugFlow(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) => { + setShowDebugFlow(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 +155,7 @@ const AuthDebugger = ({ message: "OAuth tokens cleared successfully", }, }); + setShowDebugFlow(false); // Clear success message after 3 seconds setTimeout(() => { @@ -274,25 +201,10 @@ const AuthDebugger = ({ )}
- - -

- Choose "Guided" for step-by-step instructions or "Quick" for - the standard automatic flow. + The debug flow lets you step through each OAuth request one at + a time, inspecting headers and responses.

- + {showDebugFlow && ( + + )} diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx new file mode 100644 index 000000000..90ec0ac48 --- /dev/null +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -0,0 +1,419 @@ +/** + * 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 { CheckCircle2, Circle, ExternalLink, Loader2 } from "lucide-react"; +import { createDebugFetch, DebugRequestResponse } from "@/lib/debug-middleware"; +import { DebugOAuthProvider } from "@/lib/DebugOAuthProvider"; +import { + auth, + extractWWWAuthenticateParams, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { validateRedirectUrl } from "@/utils/urlValidation"; +import { useToast } from "@/lib/hooks/useToast"; + +interface AuthDebuggerFlowProps { + serverUrl: string; + onComplete: (tokens: OAuthTokens) => void; + onCancel: () => void; + onError: (error: Error) => void; +} + +type FlowState = "running" | "waiting_continue" | "waiting_code" | "complete"; + +export function AuthDebuggerFlow({ + serverUrl, + 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); + + // Handler for middleware callback - pauses until Continue clicked + const handleRequestComplete = useCallback( + async (entry: DebugRequestResponse) => { + setCurrentStep(entry); + setFlowState("waiting_continue"); + await new Promise((resolve) => { + continueResolverRef.current = resolve; + }); + setCompletedSteps((prev) => [...prev, entry]); + setCurrentStep(null); + setFlowState("running"); + }, + [], + ); + + // 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; + + async function runDebugFlow() { + const debugFetch = createDebugFetch(handleRequestComplete); + const provider = new DebugOAuthProvider(serverUrl); + provider.setAuthCodeHandler(handleAwaitAuthCode); + + try { + // Step 1: Initialize to get 401 + 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) { + // Server may not require auth, or something else happened + if (initResponse.ok) { + onError( + new Error( + `Server returned ${initResponse.status} - authentication may not be required`, + ), + ); + } else { + onError( + new Error( + `Expected 401, got ${initResponse.status} ${initResponse.statusText}`, + ), + ); + } + return; + } + + const { resourceMetadataUrl, scope } = + extractWWWAuthenticateParams(initResponse); + + // Step 2: Run auth() - middleware captures all requests + let result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + fetchFn: debugFetch, + }); + + // Step 3: If REDIRECT, we've already gotten the code via handleAwaitAuthCode + if (result === "REDIRECT") { + const authorizationCode = provider.getPendingAuthCode(); + if (!authorizationCode) { + onError(new Error("No authorization code received")); + return; + } + + provider.clearPendingAuthCode(); + + result = await auth(provider, { + serverUrl, + resourceMetadataUrl, + scope, + authorizationCode, + fetchFn: debugFetch, + }); + } + + 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); + onError(error instanceof Error ? error : new Error(String(error))); + } + } + + runDebugFlow(); + }, [ + serverUrl, + handleRequestComplete, + handleAwaitAuthCode, + onComplete, + onError, + ]); + + const handleOpenAuthUrl = () => { + if (authUrl) { + try { + validateRedirectUrl(authUrl.href); + window.open(authUrl.href, "_blank", "noopener noreferrer"); + } 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. +

+ +
+ {/* Completed steps */} + {completedSteps.map((step, index) => ( + + ))} + + {/* Current step waiting for continue */} + {currentStep && flowState === "waiting_continue" && ( +
+ +
+ +
+
+ )} + + {/* Auth code entry */} + {flowState === "waiting_code" && authUrl && ( +
+
+

Authorization Required

+

+ Open this URL in a new tab to authorize: +

+
+ {authUrl.href} + +
+
+ +
+ +
+ setAuthCode(e.target.value)} + placeholder="Paste the authorization code here" + className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" + onKeyDown={(e) => { + if (e.key === "Enter" && authCode.trim()) { + handleSubmitCode(); + } + }} + /> + +
+

+ After authorizing in the opened window, paste the code here. +

+
+
+ )} + + {/* Running indicator */} + {flowState === "running" && + !currentStep && + completedSteps.length > 0 && ( +
+ + Processing... +
+ )} +
+
+ ); +} + +interface StepDisplayProps { + step: DebugRequestResponse; + stepNumber: number; + isComplete: boolean; + isCurrent: boolean; +} + +function StepDisplay({ + step, + stepNumber, + isComplete, + isCurrent, +}: StepDisplayProps) { + const [expanded, setExpanded] = useState(isCurrent); + + return ( +
+
setExpanded(!expanded)} + > + {isComplete ? ( + + ) : ( + + )} + + {stepNumber}. {step.label} + + + {step.response.status} {step.response.statusText} + +
+ + {expanded && ( +
+ {/* Request details */} +
+ + Request + +
+
+ {step.request.method}{" "} + {step.request.url} +
+ {Object.keys(step.request.headers).length > 0 && ( +
+

Headers:

+
+                    {JSON.stringify(step.request.headers, null, 2)}
+                  
+
+ )} + {step.request.body !== undefined && ( +
+

Body:

+
+                    {typeof step.request.body === "string"
+                      ? step.request.body
+                      : JSON.stringify(step.request.body, null, 2)}
+                  
+
+ )} +
+
+ + {/* Response details */} +
+ + Response + +
+
+ = 400 ? "text-red-600" : "text-green-600"}`} + > + {step.response.status} + {" "} + {step.response.statusText} +
+ {Object.keys(step.response.headers).length > 0 && ( +
+

Headers:

+
+                    {JSON.stringify(step.response.headers, null, 2)}
+                  
+
+ )} + {step.response.body !== undefined && ( +
+

Body:

+
+                    {typeof step.response.body === "string"
+                      ? step.response.body
+                      : JSON.stringify(step.response.body, null, 2)}
+                  
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx deleted file mode 100644 index 6e0fd6956..000000000 --- a/client/src/components/OAuthFlowProgress.tsx +++ /dev/null @@ -1,398 +0,0 @@ -import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types"; -import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; -import { Button } from "./ui/button"; -import { DebugInspectorOAuthClientProvider } from "@/lib/auth"; -import { useEffect, useMemo, useState } from "react"; -import { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { validateRedirectUrl } from "@/utils/urlValidation"; -import { useToast } from "@/lib/hooks/useToast"; - -interface OAuthStepProps { - label: string; - isComplete: boolean; - isCurrent: boolean; - error?: Error | null; - children?: React.ReactNode; -} - -const OAuthStepDetails = ({ - label, - isComplete, - isCurrent, - error, - children, -}: OAuthStepProps) => { - return ( -
-
- {isComplete ? ( - - ) : ( - - )} - {label} -
- - {/* Show children if current step or complete and children exist */} - {(isCurrent || isComplete) && children && ( -
{children}
- )} - - {/* Display error if current step and an error exists */} - {isCurrent && error && ( -
-

Error:

-

{error.message}

-
- )} -
- ); -}; - -interface OAuthFlowProgressProps { - serverUrl: string; - authState: AuthDebuggerState; - updateAuthState: (updates: Partial) => void; - proceedToNextStep: () => Promise; -} - -const steps: Array = [ - "metadata_discovery", - "client_registration", - "authorization_redirect", - "authorization_code", - "token_request", - "complete", -]; - -export const OAuthFlowProgress = ({ - serverUrl, - authState, - updateAuthState, - proceedToNextStep, -}: OAuthFlowProgressProps) => { - const { toast } = useToast(); - const provider = useMemo( - () => new DebugInspectorOAuthClientProvider(serverUrl), - [serverUrl], - ); - const [clientInfo, setClientInfo] = useState( - null, - ); - - const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); - - useEffect(() => { - const fetchClientInfo = async () => { - if (authState.oauthClientInfo) { - setClientInfo(authState.oauthClientInfo); - } else { - try { - const info = await provider.clientInformation(); - if (info) { - setClientInfo(info); - } - } catch (error) { - console.error("Failed to fetch client information:", error); - } - } - }; - - if (currentStepIdx > steps.indexOf("client_registration")) { - fetchClientInfo(); - } - }, [ - provider, - authState.oauthStep, - authState.oauthClientInfo, - currentStepIdx, - ]); - - // Helper to get step props - const getStepProps = (stepName: OAuthStep) => ({ - isComplete: - currentStepIdx > steps.indexOf(stepName) || - currentStepIdx === steps.length - 1, // last step is "complete" - isCurrent: authState.oauthStep === stepName, - error: authState.oauthStep === stepName ? authState.latestError : null, - }); - - return ( -
-

OAuth Flow Progress

-

- Follow these steps to complete OAuth authentication with the server. -

- -
- - {authState.oauthMetadata && ( -
- - OAuth Metadata Sources - {!authState.resourceMetadata && " ℹ️"} - - - {authState.resourceMetadata && ( -
-

Resource Metadata:

-

- From{" "} - { - new URL( - "/.well-known/oauth-protected-resource", - serverUrl, - ).href - } -

-
-                    {JSON.stringify(authState.resourceMetadata, null, 2)}
-                  
-
- )} - - {authState.resourceMetadataError && ( -
-

- ℹ️ Problem with resource metadata from{" "} - - { - new URL( - "/.well-known/oauth-protected-resource", - serverUrl, - ).href - } - -

-

- 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)"} -

-
- )} - - {authState.oauthMetadata && ( -
-

Authorization Server Metadata:

- {authState.authServerUrl && ( -

- From{" "} - { - new URL( - "/.well-known/oauth-authorization-server", - authState.authServerUrl, - ).href - } -

- )} -
-                    {JSON.stringify(authState.oauthMetadata, null, 2)}
-                  
-
- )} -
- )} -
- - - {clientInfo && ( -
- - Registered Client Information - -
-                {JSON.stringify(clientInfo, null, 2)}
-              
-
- )} -
- - - {authState.authorizationUrl && ( -
-

Authorization URL:

-
-

- {String(authState.authorizationUrl)} -

- -
-

- Click the link to authorize in your browser. After - authorization, you'll be redirected back to continue the flow. -

-
- )} -
- - -
- -
- { - updateAuthState({ - authorizationCode: e.target.value, - validationError: null, - }); - }} - placeholder="Enter the code from the authorization server" - className={`flex h-9 w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${ - authState.validationError ? "border-red-500" : "border-input" - }`} - /> -
- {authState.validationError && ( -

- {authState.validationError} -

- )} -

- Once you've completed authorization in the link, paste the code - here. -

-
-
- - - {authState.oauthMetadata && ( -
- - Token Request Details - -
-

Token Endpoint:

- - {authState.oauthMetadata.token_endpoint} - -
-
- )} -
- - - {authState.oauthTokens && ( -
- - Access Tokens - -

- Authentication successful! You can now use the authenticated - connection. These tokens will be used automatically for server - requests. -

-
-                {JSON.stringify(authState.oauthTokens, null, 2)}
-              
-
- )} -
-
- -
- {authState.oauthStep !== "complete" && ( - <> - - - )} - - {authState.oauthStep === "authorization_redirect" && - authState.authorizationUrl && ( - - )} -
-
- ); -}; diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 5d5042ea5..5afbf4757 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -9,7 +9,7 @@ import "@testing-library/jest-dom"; import { describe, it, beforeEach, jest } from "@jest/globals"; import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { SESSION_KEYS } from "@/lib/constants"; +import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; const mockOAuthTokens = { access_token: "test_access_token", @@ -19,117 +19,35 @@ const mockOAuthTokens = { scope: "test_scope", }; -const mockOAuthMetadata = { - issuer: "https://oauth.example.com", - authorization_endpoint: "https://oauth.example.com/authorize", - token_endpoint: "https://oauth.example.com/token", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - scopes_supported: ["read", "write"], -}; - -const mockOAuthClientInfo = { - client_id: "test_client_id", - client_secret: "test_client_secret", - redirect_uris: ["http://localhost:3000/oauth/callback/debug"], -}; - -// Mock MCP SDK functions - must be before imports +// Mock MCP SDK functions jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), - discoverAuthorizationServerMetadata: jest.fn(), - registerClient: jest.fn(), - startAuthorization: jest.fn(), - exchangeAuthorization: jest.fn(), - discoverOAuthProtectedResourceMetadata: jest.fn(), - selectResourceURL: jest.fn(), + extractWWWAuthenticateParams: jest.fn().mockReturnValue({}), })); -// Import the functions to get their types -import { - discoverAuthorizationServerMetadata, - registerClient, - startAuthorization, - exchangeAuthorization, - auth, - discoverOAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { EMPTY_DEBUGGER_STATE } from "@/lib/auth-types"; - // Mock local auth module jest.mock("@/lib/auth", () => ({ DebugInspectorOAuthClientProvider: jest.fn().mockImplementation(() => ({ tokens: jest.fn().mockImplementation(() => Promise.resolve(undefined)), clear: jest.fn().mockImplementation(() => { - // Mock the real clear() behavior which removes items from sessionStorage + // Mock the real clear() behavior sessionStorage.removeItem("[https://example.com/mcp] mcp_tokens"); - sessionStorage.removeItem("[https://example.com/mcp] mcp_client_info"); - sessionStorage.removeItem( - "[https://example.com/mcp] mcp_server_metadata", - ); - }), - redirectUrl: "http://localhost:3000/oauth/callback/debug", - clientMetadata: { - redirect_uris: ["http://localhost:3000/oauth/callback/debug"], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], - client_name: "MCP Inspector", - }, - clientInformation: jest.fn().mockImplementation(async () => { - const serverUrl = "https://example.com/mcp"; - const preregisteredKey = `[${serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}`; - const preregisteredData = sessionStorage.getItem(preregisteredKey); - if (preregisteredData) { - return JSON.parse(preregisteredData); - } - const dynamicKey = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; - const dynamicData = sessionStorage.getItem(dynamicKey); - if (dynamicData) { - return JSON.parse(dynamicData); - } - return undefined; }), - saveClientInformation: jest.fn().mockImplementation((clientInfo) => { - const serverUrl = "https://example.com/mcp"; - const key = `[${serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`; - sessionStorage.setItem(key, JSON.stringify(clientInfo)); - }), - saveTokens: jest.fn(), - redirectToAuthorization: jest.fn(), - saveCodeVerifier: jest.fn(), - codeVerifier: jest.fn(), - saveServerMetadata: jest.fn(), - getServerMetadata: jest.fn(), })), - discoverScopes: jest.fn().mockResolvedValue("read write" as never), })); -import { discoverScopes } from "@/lib/auth"; - -// Type the mocked functions properly -const mockDiscoverAuthorizationServerMetadata = - discoverAuthorizationServerMetadata as jest.MockedFunction< - typeof discoverAuthorizationServerMetadata - >; -const mockRegisterClient = registerClient as jest.MockedFunction< - typeof registerClient ->; -const mockStartAuthorization = startAuthorization as jest.MockedFunction< - typeof startAuthorization ->; -const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< - typeof exchangeAuthorization ->; -const mockAuth = auth as jest.MockedFunction; -const mockDiscoverOAuthProtectedResourceMetadata = - discoverOAuthProtectedResourceMetadata as jest.MockedFunction< - typeof discoverOAuthProtectedResourceMetadata - >; -const mockDiscoverScopes = discoverScopes as jest.MockedFunction< - typeof discoverScopes ->; +// Mock the AuthDebuggerFlow component since it has complex async behavior +jest.mock("../AuthDebuggerFlow", () => ({ + AuthDebuggerFlow: jest.fn(({ onComplete, onCancel, onError }) => ( +
+ + + +
+ )), +})); const sessionStorageMock = { getItem: jest.fn(), @@ -155,35 +73,8 @@ describe("AuthDebugger", () => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - // Suppress console errors in tests to avoid JSDOM navigation noise + // Suppress console errors in tests jest.spyOn(console, "error").mockImplementation(() => {}); - - // Set default mock behaviors with complete OAuth metadata - mockDiscoverAuthorizationServerMetadata.mockResolvedValue({ - issuer: "https://oauth.example.com", - authorization_endpoint: "https://oauth.example.com/authorize", - token_endpoint: "https://oauth.example.com/token", - response_types_supported: ["code"], - grant_types_supported: ["authorization_code"], - scopes_supported: ["read", "write"], - }); - mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); - mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue( - new Error("No protected resource metadata found"), - ); - mockStartAuthorization.mockImplementation(async (_sseUrl, options) => { - const authUrl = new URL("https://oauth.example.com/authorize"); - - if (options.scope) { - authUrl.searchParams.set("scope", options.scope); - } - - return { - authorizationUrl: authUrl, - codeVerifier: "test_verifier", - }; - }); - mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); }); afterEach(() => { @@ -219,29 +110,36 @@ describe("AuthDebugger", () => { fireEvent.click(screen.getByText("Back to Connect")); expect(onBack).toHaveBeenCalled(); }); - }); - describe("OAuth Flow", () => { - it("should start OAuth flow when 'Guided OAuth Flow' is clicked", async () => { + it("should show Start Debug Flow button when no tokens exist", async () => { await act(async () => { renderAuthDebugger(); }); + expect(screen.getByText("Start Debug Flow")).toBeInTheDocument(); + }); + it("should show Refresh Token button when tokens exist", async () => { await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); + renderAuthDebugger({ + authState: { + ...defaultAuthState, + oauthTokens: mockOAuthTokens, + }, + }); }); - - expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument(); + expect(screen.getByText("Refresh Token")).toBeInTheDocument(); }); + }); - it("should show error when OAuth flow is started without sseUrl", async () => { + describe("Debug Flow", () => { + it("should show error when debug flow is started without serverUrl", async () => { const updateAuthState = jest.fn(); await act(async () => { renderAuthDebugger({ serverUrl: "", updateAuthState }); }); await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); + fireEvent.click(screen.getByText("Start Debug Flow")); }); expect(updateAuthState).toHaveBeenCalledWith({ @@ -253,52 +151,84 @@ describe("AuthDebugger", () => { }); }); - it("should start quick OAuth flow and properly fetch and save metadata", async () => { - // Setup the auth mock - mockAuth.mockResolvedValue("AUTHORIZED"); + it("should show AuthDebuggerFlow when debug flow is started", async () => { + await act(async () => { + renderAuthDebugger(); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Start Debug Flow")); + }); + expect(screen.getByTestId("mock-auth-debugger-flow")).toBeInTheDocument(); + }); + + it("should handle flow completion", async () => { const updateAuthState = jest.fn(); await act(async () => { renderAuthDebugger({ updateAuthState }); }); await act(async () => { - fireEvent.click(screen.getByText("Quick OAuth Flow")); + fireEvent.click(screen.getByText("Start Debug Flow")); }); - // Should first discover and save OAuth metadata - expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("https://example.com/"), - ); + await act(async () => { + fireEvent.click(screen.getByText("Mock Complete")); + }); - // Check that updateAuthState was called with the right info message - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - oauthStep: "authorization_code", - }), - ); + expect(updateAuthState).toHaveBeenCalledWith({ + oauthTokens: mockOAuthTokens, + oauthStep: "complete", + statusMessage: { + type: "success", + message: "Authentication completed successfully", + }, + }); }); - it("should show error when quick OAuth flow fails to discover metadata", async () => { - mockDiscoverAuthorizationServerMetadata.mockRejectedValue( - new Error("Metadata discovery failed"), - ); + it("should handle flow cancellation", async () => { + const updateAuthState = jest.fn(); + await act(async () => { + renderAuthDebugger({ updateAuthState }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Start Debug Flow")); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Mock Cancel")); + }); + expect(updateAuthState).toHaveBeenCalledWith({ + statusMessage: { + type: "info", + message: "OAuth flow cancelled", + }, + }); + }); + + it("should handle flow error", async () => { const updateAuthState = jest.fn(); await act(async () => { renderAuthDebugger({ updateAuthState }); }); await act(async () => { - fireEvent.click(screen.getByText("Quick OAuth Flow")); + fireEvent.click(screen.getByText("Start Debug Flow")); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Mock Error")); }); - // Check that updateAuthState was called with an error message expect(updateAuthState).toHaveBeenCalledWith( expect.objectContaining({ + latestError: expect.any(Error), statusMessage: { type: "error", - message: expect.stringContaining("Failed to start OAuth flow"), + message: "OAuth flow failed: Test error", }, }), ); @@ -306,15 +236,7 @@ describe("AuthDebugger", () => { }); describe("Session Storage Integration", () => { - it("should load OAuth tokens from session storage", async () => { - // Mock the specific key for tokens with server URL - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === "[https://example.com] mcp_tokens") { - return JSON.stringify(mockOAuthTokens); - } - return null; - }); - + it("should display OAuth tokens when they exist", async () => { await act(async () => { renderAuthDebugger({ authState: { @@ -357,13 +279,6 @@ describe("AuthDebugger", () => { describe("OAuth State Management", () => { it("should clear OAuth state when Clear button is clicked", async () => { const updateAuthState = jest.fn(); - // Mock the session storage to return tokens for the specific key - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === "[https://example.com] mcp_tokens") { - return JSON.stringify(mockOAuthTokens); - } - return null; - }); await act(async () => { renderAuthDebugger({ @@ -404,394 +319,53 @@ describe("AuthDebugger", () => { }); }); - describe("OAuth Flow Steps", () => { - it("should handle OAuth flow step progression", async () => { - const updateAuthState = jest.fn(); + describe("Status Messages", () => { + it("should display success messages", async () => { await act(async () => { renderAuthDebugger({ - updateAuthState, authState: { ...defaultAuthState, - isInitiatingAuth: false, // Changed to false so button is enabled - oauthStep: "metadata_discovery", + statusMessage: { + type: "success", + message: "Test success message", + }, }, }); }); - // Verify metadata discovery step - expect(screen.getByText("Metadata Discovery")).toBeInTheDocument(); - - // Click Continue - this should trigger metadata discovery - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("https://example.com/"), - ); + expect(screen.getByText("Test success message")).toBeInTheDocument(); }); - // Setup helper for OAuth authorization tests - const setupAuthorizationUrlTest = async (metadata: OAuthMetadata) => { - const updateAuthState = jest.fn(); - - // Mock the session storage to return metadata - sessionStorageMock.getItem.mockImplementation((key) => { - if (key === `[https://example.com] ${SESSION_KEYS.SERVER_METADATA}`) { - return JSON.stringify(metadata); - } - if ( - key === `[https://example.com] ${SESSION_KEYS.CLIENT_INFORMATION}` - ) { - return JSON.stringify(mockOAuthClientInfo); - } - return null; - }); - + it("should display error messages", async () => { await act(async () => { renderAuthDebugger({ - updateAuthState, authState: { ...defaultAuthState, - isInitiatingAuth: false, - oauthStep: "authorization_redirect", - oauthMetadata: metadata, - oauthClientInfo: mockOAuthClientInfo, + statusMessage: { + type: "error", + message: "Test error message", + }, }, }); }); - // Click Continue to trigger authorization - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - return updateAuthState; - }; - - it("should include scope in authorization URL when scopes_supported is present", async () => { - const metadataWithScopes = { - ...mockOAuthMetadata, - scopes_supported: ["read", "write", "admin"], - }; - - const updateAuthState = - await setupAuthorizationUrlTest(metadataWithScopes); - - // Wait for the updateAuthState to be called - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUrl: expect.objectContaining({ - href: "https://oauth.example.com/authorize?scope=read+write", - }), - }), - ); - }); + expect(screen.getByText("Test error message")).toBeInTheDocument(); }); - it("should include scope in authorization URL when scopes_supported is not present", async () => { - const updateAuthState = - await setupAuthorizationUrlTest(mockOAuthMetadata); - - // Wait for the updateAuthState to be called - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUrl: expect.objectContaining({ - href: "https://oauth.example.com/authorize?scope=read+write", - }), - }), - ); - }); - }); - - it("should omit scope from authorization URL when discoverScopes returns undefined", async () => { - // Mock discoverScopes to return undefined (no scopes available) - mockDiscoverScopes.mockResolvedValueOnce(undefined); - - const updateAuthState = - await setupAuthorizationUrlTest(mockOAuthMetadata); - - // Wait for the updateAuthState to be called - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - authorizationUrl: expect.not.stringContaining("scope="), - }), - ); - }); - }); - }); - - describe("Client Registration behavior", () => { - it("uses preregistered (static) client information without calling DCR", async () => { - const preregClientInfo = { - client_id: "static_client_id", - client_secret: "static_client_secret", - redirect_uris: ["http://localhost:3000/oauth/callback/debug"], - }; - - // Return preregistered client info for the server-specific key - sessionStorageMock.getItem.mockImplementation((key) => { - if ( - key === - `[${defaultProps.serverUrl}] ${SESSION_KEYS.PREREGISTERED_CLIENT_INFORMATION}` - ) { - return JSON.stringify(preregClientInfo); - } - return null; - }); - - const updateAuthState = jest.fn(); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { - ...defaultAuthState, - isInitiatingAuth: false, - oauthStep: "client_registration", - oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, - }, - }); - }); - - // Proceed from client_registration → authorization_redirect - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - // Should NOT attempt dynamic client registration - expect(mockRegisterClient).not.toHaveBeenCalled(); - - // Should advance with the preregistered client info - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - oauthClientInfo: expect.objectContaining({ - client_id: "static_client_id", - }), - oauthStep: "authorization_redirect", - }), - ); - }); - - it("falls back to DCR when no static client information is available", async () => { - // No preregistered or dynamic client info present in session storage - sessionStorageMock.getItem.mockImplementation(() => null); - - // DCR returns a new client - mockRegisterClient.mockResolvedValueOnce(mockOAuthClientInfo); - - const updateAuthState = jest.fn(); - + it("should display info messages", async () => { await act(async () => { renderAuthDebugger({ - updateAuthState, authState: { ...defaultAuthState, - isInitiatingAuth: false, - oauthStep: "client_registration", - oauthMetadata: mockOAuthMetadata as unknown as OAuthMetadata, + statusMessage: { + type: "info", + message: "Test info message", + }, }, }); }); - await act(async () => { - fireEvent.click(screen.getByText("Continue")); - }); - - expect(mockRegisterClient).toHaveBeenCalledTimes(1); - - // Should save and advance with the DCR client info - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - oauthClientInfo: expect.objectContaining({ - client_id: "test_client_id", - }), - oauthStep: "authorization_redirect", - }), - ); - - // Verify the dynamically registered client info was persisted - expect(sessionStorage.setItem).toHaveBeenCalledWith( - `[${defaultProps.serverUrl}] ${SESSION_KEYS.CLIENT_INFORMATION}`, - expect.any(String), - ); - }); - }); - - describe("OAuth State Persistence", () => { - it("should store auth state to sessionStorage before redirect in Quick OAuth Flow", async () => { - const updateAuthState = jest.fn(); - - // Setup mocks for OAuth flow - mockStartAuthorization.mockResolvedValue({ - authorizationUrl: new URL( - "https://oauth.example.com/authorize?client_id=test_client_id&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Foauth%2Fcallback%2Fdebug", - ), - codeVerifier: "test_verifier", - }); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { ...defaultAuthState }, - }); - }); - - // Click Quick OAuth Flow - await act(async () => { - fireEvent.click(screen.getByText("Quick OAuth Flow")); - }); - - // Wait for the flow to reach the authorization step - await waitFor(() => { - expect(sessionStorage.setItem).toHaveBeenCalledWith( - SESSION_KEYS.AUTH_DEBUGGER_STATE, - expect.stringContaining('"oauthStep":"authorization_code"'), - ); - }); - - // Verify the stored state includes all the accumulated data - const storedStateCall = ( - sessionStorage.setItem as jest.Mock - ).mock.calls.find((call) => call[0] === SESSION_KEYS.AUTH_DEBUGGER_STATE); - - expect(storedStateCall).toBeDefined(); - const storedState = JSON.parse(storedStateCall![1] as string); - - expect(storedState).toMatchObject({ - oauthStep: "authorization_code", - authorizationUrl: expect.stringMatching( - /^https:\/\/oauth\.example\.com\/authorize/, - ), - oauthMetadata: expect.objectContaining({ - token_endpoint: "https://oauth.example.com/token", - }), - oauthClientInfo: expect.objectContaining({ - client_id: "test_client_id", - }), - }); - }); - }); - - describe("OAuth Protected Resource Metadata", () => { - it("should successfully fetch and display protected resource metadata", async () => { - const updateAuthState = jest.fn(); - const mockResourceMetadata = { - resource: "https://example.com/mcp", - authorization_servers: ["https://custom-auth.example.com"], - bearer_methods_supported: ["header", "body"], - resource_documentation: "https://example.com/mcp/docs", - resource_policy_uri: "https://example.com/mcp/policy", - }; - - // Mock successful metadata discovery - mockDiscoverOAuthProtectedResourceMetadata.mockResolvedValue( - mockResourceMetadata, - ); - mockDiscoverAuthorizationServerMetadata.mockResolvedValue( - mockOAuthMetadata, - ); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { ...defaultAuthState }, - }); - }); - - // Click Guided OAuth Flow to start the process - await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); - }); - - // Verify that the flow started with metadata discovery - expect(updateAuthState).toHaveBeenCalledWith({ - oauthStep: "metadata_discovery", - authorizationUrl: null, - statusMessage: null, - latestError: null, - }); - - // Click Continue to trigger metadata discovery - const continueButton = await screen.findByText("Continue"); - await act(async () => { - fireEvent.click(continueButton); - }); - - // Wait for the metadata to be fetched - await waitFor(() => { - expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com/mcp", - ); - }); - - // Verify the state was updated with the resource metadata - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadata: mockResourceMetadata, - authServerUrl: new URL("https://custom-auth.example.com"), - oauthStep: "client_registration", - }), - ); - }); - }); - - it("should handle protected resource metadata fetch failure gracefully", async () => { - const updateAuthState = jest.fn(); - const mockError = new Error("Failed to fetch resource metadata"); - - // Mock failed metadata discovery - mockDiscoverOAuthProtectedResourceMetadata.mockRejectedValue(mockError); - // But OAuth metadata should still work with the original URL - mockDiscoverAuthorizationServerMetadata.mockResolvedValue( - mockOAuthMetadata, - ); - - await act(async () => { - renderAuthDebugger({ - updateAuthState, - authState: { ...defaultAuthState }, - }); - }); - - // Click Guided OAuth Flow - await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); - }); - - // Click Continue to trigger metadata discovery - const continueButton = await screen.findByText("Continue"); - await act(async () => { - fireEvent.click(continueButton); - }); - - // Wait for the metadata fetch to fail - await waitFor(() => { - expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( - "https://example.com/mcp", - ); - }); - - // Verify the flow continues despite the error - await waitFor(() => { - expect(updateAuthState).toHaveBeenCalledWith( - expect.objectContaining({ - resourceMetadataError: mockError, - // Should use the original server URL as fallback - authServerUrl: new URL("https://example.com/"), - oauthStep: "client_registration", - }), - ); - }); - - // Verify that regular OAuth metadata discovery was still called - expect(mockDiscoverAuthorizationServerMetadata).toHaveBeenCalledWith( - new URL("https://example.com/"), - ); + expect(screen.getByText("Test info message")).toBeInTheDocument(); }); }); }); diff --git a/client/src/lib/DebugOAuthProvider.ts b/client/src/lib/DebugOAuthProvider.ts new file mode 100644 index 000000000..33ad1c819 --- /dev/null +++ b/client/src/lib/DebugOAuthProvider.ts @@ -0,0 +1,185 @@ +/** + * OAuth provider for the debug flow that intercepts the authorization redirect + * and allows manual entry of the authorization code. + */ + +import { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import { + OAuthClientInformation, + OAuthClientMetadata, + OAuthTokens, + OAuthTokensSchema, + OAuthClientInformationSchema, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { SESSION_KEYS, getServerSpecificKey } from "./constants"; +import { generateOAuthState } from "@/utils/oauthUtils"; + +/** + * Handler type for when the SDK wants to redirect to authorization. + * The handler receives the authorization URL and should return the + * authorization code once the user completes the flow. + */ +export type AuthCodeHandler = (url: URL) => Promise; + +/** + * Debug OAuth provider that extends the base provider to intercept + * authorization redirects and allow manual code entry. + */ +export class DebugOAuthProvider implements OAuthClientProvider { + private _authCodeHandler: AuthCodeHandler | null = null; + private _pendingAuthCode: string | null = null; + + constructor(private serverUrl: string) { + // Save the server URL to session storage + sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); + } + + /** + * Sets the handler that will be called when authorization is needed. + * The handler should display the URL to the user and wait for them + * to enter the authorization code. + */ + setAuthCodeHandler(handler: AuthCodeHandler) { + this._authCodeHandler = handler; + } + + /** + * Returns any pending authorization code that was received. + */ + getPendingAuthCode(): string | null { + return this._pendingAuthCode; + } + + /** + * Clears the pending authorization code. + */ + clearPendingAuthCode() { + this._pendingAuthCode = null; + } + + get redirectUrl(): string { + return window.location.origin + "/oauth/callback/debug"; + } + + get clientMetadata(): OAuthClientMetadata { + const metadata: OAuthClientMetadata = { + redirect_uris: [this.redirectUrl], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: "MCP Inspector", + client_uri: "https://github.com/modelcontextprotocol/inspector", + }; + return metadata; + } + + state(): string | Promise { + return generateOAuthState(); + } + + async clientInformation(): Promise { + const key = getServerSpecificKey( + SESSION_KEYS.CLIENT_INFORMATION, + this.serverUrl, + ); + const value = sessionStorage.getItem(key); + if (!value) { + return undefined; + } + return await OAuthClientInformationSchema.parseAsync(JSON.parse(value)); + } + + saveClientInformation(clientInformation: OAuthClientInformation) { + const key = getServerSpecificKey( + SESSION_KEYS.CLIENT_INFORMATION, + this.serverUrl, + ); + sessionStorage.setItem(key, JSON.stringify(clientInformation)); + } + + async tokens(): Promise { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); + const tokens = sessionStorage.getItem(key); + if (!tokens) { + return undefined; + } + return await OAuthTokensSchema.parseAsync(JSON.parse(tokens)); + } + + saveTokens(tokens: OAuthTokens) { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, this.serverUrl); + sessionStorage.setItem(key, JSON.stringify(tokens)); + } + + /** + * Instead of redirecting, this calls the auth code handler and waits + * for the user to manually enter the code. + */ + async redirectToAuthorization(authorizationUrl: URL): Promise { + if (!this._authCodeHandler) { + throw new Error( + "Auth code handler not set - call setAuthCodeHandler before starting auth flow", + ); + } + // This blocks until user enters the code in the UI + const code = await this._authCodeHandler(authorizationUrl); + // Store code for retrieval by the caller + this._pendingAuthCode = code; + } + + saveCodeVerifier(codeVerifier: string) { + const key = getServerSpecificKey( + SESSION_KEYS.CODE_VERIFIER, + this.serverUrl, + ); + sessionStorage.setItem(key, codeVerifier); + } + + codeVerifier(): string { + const key = getServerSpecificKey( + SESSION_KEYS.CODE_VERIFIER, + this.serverUrl, + ); + const verifier = sessionStorage.getItem(key); + if (!verifier) { + throw new Error("No code verifier saved for session"); + } + return verifier; + } + + // Additional methods for saving/retrieving server metadata for display + + saveServerMetadata(metadata: OAuthMetadata) { + const key = getServerSpecificKey( + SESSION_KEYS.SERVER_METADATA, + this.serverUrl, + ); + sessionStorage.setItem(key, JSON.stringify(metadata)); + } + + getServerMetadata(): OAuthMetadata | null { + const key = getServerSpecificKey( + SESSION_KEYS.SERVER_METADATA, + this.serverUrl, + ); + const metadata = sessionStorage.getItem(key); + if (!metadata) { + return null; + } + return JSON.parse(metadata); + } + + clear() { + const keys = [ + SESSION_KEYS.CLIENT_INFORMATION, + SESSION_KEYS.TOKENS, + SESSION_KEYS.CODE_VERIFIER, + SESSION_KEYS.SERVER_METADATA, + ]; + for (const baseKey of keys) { + const key = getServerSpecificKey(baseKey, this.serverUrl); + sessionStorage.removeItem(key); + } + } +} diff --git a/client/src/lib/debug-middleware.ts b/client/src/lib/debug-middleware.ts new file mode 100644 index 000000000..e390ba5d1 --- /dev/null +++ b/client/src/lib/debug-middleware.ts @@ -0,0 +1,170 @@ +/** + * Debug middleware for capturing HTTP request/response pairs with pause functionality. + * Used by the auth debugger to show each OAuth request individually. + */ + +export interface DebugRequestResponse { + id: string; + label: string; + request: { + method: string; + url: string; + headers: Record; + body?: unknown; + }; + response: { + status: number; + statusText: string; + headers: Record; + body?: unknown; + }; +} + +export type DebugMiddlewareCallback = ( + entry: DebugRequestResponse, +) => Promise; + +/** + * Creates a fetch function that captures request/response pairs and pauses + * after each request completes, waiting for the callback to resolve. + * + * @param onComplete - Callback invoked after each request/response. The flow + * pauses until this promise resolves. + * @returns A fetch-like function that can be passed to SDK auth functions. + */ +export function createDebugFetch( + onComplete: DebugMiddlewareCallback, +): typeof fetch { + return async (input, init) => { + const url = typeof input === "string" ? input : input.toString(); + const method = init?.method || "GET"; + + // Make the actual request + const response = await fetch(input, init); + + // Clone to read body without consuming + const clonedResponse = response.clone(); + let responseBody: unknown; + try { + responseBody = await clonedResponse.json(); + } catch { + try { + responseBody = await clonedResponse.text(); + } catch { + responseBody = null; + } + } + + // Parse request body if present + let requestBody: unknown = undefined; + if (init?.body) { + requestBody = parseBody(init.body); + } + + // Build entry and wait for user to continue + const entry: DebugRequestResponse = { + id: crypto.randomUUID(), + label: inferLabel(url, method), + request: { + method, + url, + headers: headersToObject(init?.headers), + body: requestBody, + }, + response: { + status: response.status, + statusText: response.statusText, + headers: headersToObject(response.headers), + body: responseBody, + }, + }; + + await onComplete(entry); // Blocks until user clicks Continue + + return response; + }; +} + +/** + * Infers a human-readable label for an OAuth-related request based on URL patterns. + */ +function inferLabel(url: string, method: string): string { + if (url.includes(".well-known/oauth-protected-resource")) { + return "Resource Metadata"; + } + if ( + url.includes(".well-known/oauth-authorization-server") || + url.includes(".well-known/openid-configuration") + ) { + return "Auth Server Metadata"; + } + if (url.includes("/register")) { + return "Client Registration"; + } + if (url.includes("/token")) { + return "Token Exchange"; + } + try { + const parsed = new URL(url); + return `${method} ${parsed.pathname}`; + } catch { + return `${method} ${url}`; + } +} + +/** + * Converts various header formats to a plain object. + */ +function headersToObject( + headers?: HeadersInit | Headers, +): Record { + if (!headers) return {}; + + if (headers instanceof Headers) { + const obj: Record = {}; + headers.forEach((value, key) => { + obj[key] = value; + }); + return obj; + } + + if (Array.isArray(headers)) { + return Object.fromEntries(headers); + } + + return headers as Record; +} + +/** + * Parses a request body into a displayable format. + */ +function parseBody(body: BodyInit | null | undefined): unknown { + if (!body) return undefined; + + if (typeof body === "string") { + try { + return JSON.parse(body); + } catch { + // Check if it's URL-encoded form data + if (body.includes("=") && body.includes("&")) { + const params = new URLSearchParams(body); + const obj: Record = {}; + params.forEach((value, key) => { + obj[key] = value; + }); + return obj; + } + return body; + } + } + + if (body instanceof URLSearchParams) { + const obj: Record = {}; + body.forEach((value, key) => { + obj[key] = value; + }); + return obj; + } + + return "[Binary or unsupported body type]"; +} diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts deleted file mode 100644 index 8dc9da8f9..000000000 --- a/client/src/lib/oauth-state-machine.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { OAuthStep, AuthDebuggerState } from "./auth-types"; -import { DebugInspectorOAuthClientProvider, discoverScopes } from "./auth"; -import { - discoverAuthorizationServerMetadata, - registerClient, - startAuthorization, - exchangeAuthorization, - discoverOAuthProtectedResourceMetadata, - selectResourceURL, -} from "@modelcontextprotocol/sdk/client/auth.js"; -import { - OAuthMetadataSchema, - OAuthProtectedResourceMetadata, -} from "@modelcontextprotocol/sdk/shared/auth.js"; -import { generateOAuthState } from "@/utils/oauthUtils"; - -export interface StateMachineContext { - state: AuthDebuggerState; - serverUrl: string; - provider: DebugInspectorOAuthClientProvider; - updateState: (updates: Partial) => void; -} - -export interface StateTransition { - canTransition: (context: StateMachineContext) => Promise; - execute: (context: StateMachineContext) => Promise; -} - -// State machine transitions -export const oauthTransitions: Record = { - metadata_discovery: { - canTransition: async () => true, - execute: async (context) => { - // Default to discovering from the server's URL - let authServerUrl = new URL("/", context.serverUrl); - let resourceMetadata: OAuthProtectedResourceMetadata | null = null; - let resourceMetadataError: Error | null = null; - try { - resourceMetadata = await discoverOAuthProtectedResourceMetadata( - context.serverUrl, - ); - if (resourceMetadata?.authorization_servers?.length) { - authServerUrl = new URL(resourceMetadata.authorization_servers[0]); - } - } catch (e) { - if (e instanceof Error) { - resourceMetadataError = e; - } else { - resourceMetadataError = new Error(String(e)); - } - } - - const resource: URL | undefined = await selectResourceURL( - context.serverUrl, - context.provider, - // we default to null, so swap it for undefined if not set - resourceMetadata ?? undefined, - ); - - const metadata = await discoverAuthorizationServerMetadata(authServerUrl); - if (!metadata) { - throw new Error("Failed to discover OAuth metadata"); - } - const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); - context.provider.saveServerMetadata(parsedMetadata); - context.updateState({ - resourceMetadata, - resource, - resourceMetadataError, - authServerUrl, - oauthMetadata: parsedMetadata, - oauthStep: "client_registration", - }); - }, - }, - - client_registration: { - canTransition: async (context) => !!context.state.oauthMetadata, - execute: async (context) => { - const metadata = context.state.oauthMetadata!; - const clientMetadata = context.provider.clientMetadata; - - // Priority: user-provided scope > discovered scopes - if (!context.provider.scope || context.provider.scope.trim() === "") { - // Prefer scopes from resource metadata if available - const scopesSupported = - context.state.resourceMetadata?.scopes_supported || - metadata.scopes_supported; - // Add all supported scopes to client registration - if (scopesSupported) { - clientMetadata.scope = scopesSupported.join(" "); - } - } - - // Try Static client first, with DCR as fallback - let fullInformation = await context.provider.clientInformation(); - if (!fullInformation) { - fullInformation = await registerClient(context.serverUrl, { - metadata, - clientMetadata, - }); - context.provider.saveClientInformation(fullInformation); - } - - context.updateState({ - oauthClientInfo: fullInformation, - oauthStep: "authorization_redirect", - }); - }, - }, - - authorization_redirect: { - canTransition: async (context) => - !!context.state.oauthMetadata && !!context.state.oauthClientInfo, - execute: async (context) => { - const metadata = context.state.oauthMetadata!; - const clientInformation = context.state.oauthClientInfo!; - - // Priority: user-provided scope > discovered scopes - let scope = context.provider.scope; - if (!scope || scope.trim() === "") { - scope = await discoverScopes( - context.serverUrl, - context.state.resourceMetadata ?? undefined, - ); - } - - const { authorizationUrl, codeVerifier } = await startAuthorization( - context.serverUrl, - { - metadata, - clientInformation, - redirectUrl: context.provider.redirectUrl, - scope, - state: generateOAuthState(), - resource: context.state.resource ?? undefined, - }, - ); - - context.provider.saveCodeVerifier(codeVerifier); - context.updateState({ - authorizationUrl: authorizationUrl, - oauthStep: "authorization_code", - }); - }, - }, - - authorization_code: { - canTransition: async () => true, - execute: async (context) => { - if ( - !context.state.authorizationCode || - context.state.authorizationCode.trim() === "" - ) { - context.updateState({ - validationError: "You need to provide an authorization code", - }); - // Don't advance if no code - throw new Error("Authorization code required"); - } - context.updateState({ - validationError: null, - oauthStep: "token_request", - }); - }, - }, - - token_request: { - canTransition: async (context) => { - return ( - !!context.state.authorizationCode && - !!context.provider.getServerMetadata() && - !!(await context.provider.clientInformation()) - ); - }, - execute: async (context) => { - const codeVerifier = context.provider.codeVerifier(); - const metadata = context.provider.getServerMetadata()!; - const clientInformation = (await context.provider.clientInformation())!; - - const tokens = await exchangeAuthorization(context.serverUrl, { - metadata, - clientInformation, - authorizationCode: context.state.authorizationCode, - codeVerifier, - redirectUri: context.provider.redirectUrl, - resource: context.state.resource - ? context.state.resource instanceof URL - ? context.state.resource - : new URL(context.state.resource) - : undefined, - }); - - context.provider.saveTokens(tokens); - context.updateState({ - oauthTokens: tokens, - oauthStep: "complete", - }); - }, - }, - - complete: { - canTransition: async () => false, - execute: async () => { - // No-op for complete state - }, - }, -}; - -export class OAuthStateMachine { - constructor( - private serverUrl: string, - private updateState: (updates: Partial) => void, - ) {} - - async executeStep(state: AuthDebuggerState): Promise { - const provider = new DebugInspectorOAuthClientProvider(this.serverUrl); - const context: StateMachineContext = { - state, - serverUrl: this.serverUrl, - provider, - updateState: this.updateState, - }; - - const transition = oauthTransitions[state.oauthStep]; - if (!(await transition.canTransition(context))) { - throw new Error(`Cannot transition from ${state.oauthStep}`); - } - - await transition.execute(context); - } -} diff --git a/package-lock.json b/package-lock.json index 0055b4334..ab3bfe74e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -227,6 +227,7 @@ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -240,7 +241,8 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -273,7 +275,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -819,6 +820,7 @@ } ], "license": "MIT-0", + "peer": true, "engines": { "node": ">=18" } @@ -839,6 +841,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -863,6 +866,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" @@ -1884,6 +1888,7 @@ "integrity": "sha512-kazxw2L9IPuZpQ0mEt9lu9Z98SqR74xcagANmMBU16X0lS23yPc0+S6hGLUz8kVRlomZEs/5S/Zlpqwf5yu6OQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/fake-timers": "30.2.0", @@ -1912,6 +1917,7 @@ "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/fake-timers": "30.2.0", "@jest/types": "30.2.0", @@ -1928,6 +1934,7 @@ "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@sinonjs/fake-timers": "^13.0.0", @@ -1946,6 +1953,7 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -1959,6 +1967,7 @@ "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", @@ -1977,7 +1986,8 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jest/environment-jsdom-abstract/node_modules/@sinonjs/fake-timers": { "version": "13.0.5", @@ -1985,6 +1995,7 @@ "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.1" } @@ -1995,6 +2006,7 @@ "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", @@ -2007,6 +2019,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2026,6 +2039,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2036,6 +2050,7 @@ "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", @@ -2057,6 +2072,7 @@ "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -2072,6 +2088,7 @@ "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -2090,6 +2107,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2103,6 +2121,7 @@ "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", @@ -2117,7 +2136,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jest/expect": { "version": "29.7.0", @@ -2186,6 +2206,7 @@ "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" @@ -2200,6 +2221,7 @@ "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } @@ -3945,7 +3967,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -4195,7 +4218,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz", "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4234,7 +4256,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4246,7 +4267,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4387,7 +4407,6 @@ "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.49.0", "@typescript-eslint/types": "8.49.0", @@ -4770,7 +4789,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5243,7 +5261,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6083,7 +6100,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/domexception": { "version": "4.0.0", @@ -6341,7 +6359,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6639,7 +6656,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7733,7 +7749,6 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -8074,6 +8089,7 @@ "integrity": "sha512-zbBTiqr2Vl78pKp/laGBREYzbZx9ZtqPjOK4++lL4BNDhxRnahg51HtoDrk9/VjIy9IthNEWdKVd7H5bqBhiWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/environment": "30.2.0", "@jest/environment-jsdom-abstract": "30.2.0", @@ -8099,6 +8115,7 @@ "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/fake-timers": "30.2.0", "@jest/types": "30.2.0", @@ -8115,6 +8132,7 @@ "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@sinonjs/fake-timers": "^13.0.0", @@ -8133,6 +8151,7 @@ "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sinclair/typebox": "^0.34.0" }, @@ -8146,6 +8165,7 @@ "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", @@ -8164,7 +8184,8 @@ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jest-environment-jsdom/node_modules/@sinonjs/fake-timers": { "version": "13.0.5", @@ -8172,6 +8193,7 @@ "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "@sinonjs/commons": "^3.0.1" } @@ -8182,6 +8204,7 @@ "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", @@ -8194,6 +8217,7 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 14" } @@ -8204,6 +8228,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8223,6 +8248,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8233,6 +8259,7 @@ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -8247,6 +8274,7 @@ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -8261,6 +8289,7 @@ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -8274,6 +8303,7 @@ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -8288,6 +8318,7 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -8302,6 +8333,7 @@ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -8315,6 +8347,7 @@ "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.2.0", @@ -8336,6 +8369,7 @@ "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -8351,6 +8385,7 @@ "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/types": "30.2.0", "@types/node": "*", @@ -8369,6 +8404,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8409,6 +8445,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8422,6 +8459,7 @@ "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", @@ -8436,7 +8474,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/jest-environment-jsdom/node_modules/tough-cookie": { "version": "5.1.2", @@ -8444,6 +8483,7 @@ "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "tldts": "^6.1.32" }, @@ -8457,6 +8497,7 @@ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "punycode": "^2.3.1" }, @@ -8470,6 +8511,7 @@ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -8483,6 +8525,7 @@ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "iconv-lite": "0.6.3" }, @@ -8496,6 +8539,7 @@ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -8506,6 +8550,7 @@ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -8520,6 +8565,7 @@ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=18" } @@ -9114,7 +9160,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -9153,7 +9198,6 @@ "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", @@ -9492,6 +9536,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10369,7 +10414,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -10545,6 +10589,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10560,6 +10605,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -10714,7 +10760,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10727,7 +10772,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -10741,7 +10785,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -11149,7 +11194,8 @@ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/run-applescript": { "version": "7.1.0", @@ -11815,7 +11861,6 @@ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -11954,7 +11999,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11978,6 +12022,7 @@ "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tldts-core": "^6.1.86" }, @@ -11990,7 +12035,8 @@ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tmpl": { "version": "1.0.5", @@ -12163,7 +12209,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -12220,7 +12265,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -12774,7 +12818,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12983,7 +13026,6 @@ "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -13077,7 +13119,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13497,7 +13538,6 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -13585,7 +13625,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From bcbb955eb84bb46310ba4ce2ebfad73aa854c169 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 18:44:57 +0000 Subject: [PATCH 02/16] fix(auth-debugger): add loading indicator and better error handling - Show 'Connecting to server...' indicator when flow starts - Add specific error handling for network errors (CORS, connection refused) - Display more helpful error messages for common failure cases --- client/src/components/AuthDebuggerFlow.tsx | 61 ++++++++++++++-------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index 90ec0ac48..f8cce7f07 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -100,20 +100,33 @@ export function AuthDebuggerFlow({ try { // Step 1: Initialize to get 401 - 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, - }), - }); + let initResponse: Response; + try { + 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, + }), + }); + } catch (fetchError) { + // Network errors (CORS, connection refused, etc.) + const message = + fetchError instanceof TypeError + ? `Network error connecting to ${serverUrl}. This could be a CORS issue or the server may not be reachable.` + : fetchError instanceof Error + ? fetchError.message + : String(fetchError); + onError(new Error(message)); + return; + } if (initResponse.status !== 401) { // Server may not require auth, or something else happened @@ -297,15 +310,17 @@ export function AuthDebuggerFlow({ )} - {/* Running indicator */} - {flowState === "running" && - !currentStep && - completedSteps.length > 0 && ( -
- - Processing... -
- )} + {/* Running indicator - show when running and no current step to display */} + {flowState === "running" && !currentStep && ( +
+ + + {completedSteps.length === 0 + ? "Connecting to server..." + : "Processing..."} + +
+ )} ); From 2145e4d2fbf4247fb25c524ee3f08dad8d558cd5 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 18:54:29 +0000 Subject: [PATCH 03/16] fix(auth-debugger): memoize AuthDebugger to prevent state loss on re-render The AuthDebuggerWrapper component was defined inside App's render function, causing React to treat it as a new component type on every App re-render. This unmounted the AuthDebugger component and reset its local state (showDebugFlow) to false, making the debug flow never appear. Fixed by using useMemo to memoize the AuthDebugger element, which preserves the component identity across re-renders and maintains its local state. --- client/src/App.tsx | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 5bee07b79..ee03b995d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -38,6 +38,7 @@ import React, { Suspense, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -1139,15 +1140,21 @@ const App = () => { setLogLevel(level); }; - const AuthDebuggerWrapper = () => ( - - setIsAuthDebuggerVisible(false)} - authState={authState} - updateAuthState={updateAuthState} - /> - + // Memoize the AuthDebugger to prevent re-mounting when App re-renders + // This is important because AuthDebugger has local state (showDebugFlow) + // that would be lost if the component was re-mounted. + const authDebuggerElement = useMemo( + () => ( + + setIsAuthDebuggerVisible(false)} + authState={authState} + updateAuthState={updateAuthState} + /> + + ), + [sseUrl, authState, updateAuthState], ); if (window.location.pathname === "/oauth/callback") { @@ -1484,7 +1491,7 @@ const App = () => { setRoots={setRoots} onRootsChange={handleRootsChange} /> - + {authDebuggerElement} { className="w-full p-4" onValueChange={(value) => (window.location.hash = value)} > - + {authDebuggerElement} ) : (
From fdb7ba90b231f91ba85278b615f0bb45cdb3058d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 18:56:07 +0000 Subject: [PATCH 04/16] feat(auth-debugger): use raw verb+path labels for requests Changed request labels to show just the HTTP method and path (e.g., 'POST /.well-known/oauth-protected-resource') instead of descriptive names like 'Resource Metadata'. This provides a more raw view of the actual HTTP requests being made. The resourceMetadataUrl extracted from the initial 401 WWW-Authenticate header is already passed to both auth() calls, ensuring consistent metadata discovery throughout the flow. --- client/src/lib/debug-middleware.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/client/src/lib/debug-middleware.ts b/client/src/lib/debug-middleware.ts index e390ba5d1..49380c0ee 100644 --- a/client/src/lib/debug-middleware.ts +++ b/client/src/lib/debug-middleware.ts @@ -86,24 +86,9 @@ export function createDebugFetch( } /** - * Infers a human-readable label for an OAuth-related request based on URL patterns. + * Creates a raw label showing the HTTP method and path. */ function inferLabel(url: string, method: string): string { - if (url.includes(".well-known/oauth-protected-resource")) { - return "Resource Metadata"; - } - if ( - url.includes(".well-known/oauth-authorization-server") || - url.includes(".well-known/openid-configuration") - ) { - return "Auth Server Metadata"; - } - if (url.includes("/register")) { - return "Client Registration"; - } - if (url.includes("/token")) { - return "Token Exchange"; - } try { const parsed = new URL(url); return `${method} ${parsed.pathname}`; From dd3e9fce6adf1b10427cf77fa1d169e21b11dab6 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 18:58:32 +0000 Subject: [PATCH 05/16] fix(auth-debugger): use exchangeAuthorization directly to avoid duplicate metadata fetch Instead of calling auth() twice (which re-discovers metadata each time), we now: 1. Use auth() for the first phase (discovery, registration, auth start) 2. Capture auth server metadata from responses during the first phase 3. Use exchangeAuthorization() directly for the token exchange This avoids the redundant metadata discovery requests that auth() would make on the second call. TODO: The SDK's auth() function should accept pre-fetched metadata to avoid this workaround. Added a comment noting this limitation. --- client/src/components/AuthDebuggerFlow.tsx | 83 +++++++++++++++++++--- 1 file changed, 74 insertions(+), 9 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index f8cce7f07..61159e945 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -10,9 +10,13 @@ import { createDebugFetch, DebugRequestResponse } from "@/lib/debug-middleware"; import { DebugOAuthProvider } from "@/lib/DebugOAuthProvider"; import { auth, + exchangeAuthorization, extractWWWAuthenticateParams, } from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + OAuthTokens, + OAuthMetadata, +} from "@modelcontextprotocol/sdk/shared/auth.js"; import { validateRedirectUrl } from "@/utils/urlValidation"; import { useToast } from "@/lib/hooks/useToast"; @@ -47,6 +51,16 @@ export function AuthDebuggerFlow({ const authCodeResolverRef = useRef<((code: string) => void) | null>(null); const flowStartedRef = useRef(false); + // 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; + }>({}); + // Handler for middleware callback - pauses until Continue clicked const handleRequestComplete = useCallback( async (entry: DebugRequestResponse) => { @@ -94,7 +108,33 @@ export function AuthDebuggerFlow({ flowStartedRef.current = true; async function runDebugFlow() { - const debugFetch = createDebugFetch(handleRequestComplete); + const baseDebugFetch = createDebugFetch(handleRequestComplete); + + // Wrap debugFetch to capture auth server metadata from responses + const debugFetch: typeof fetch = async (input, init) => { + const response = await baseDebugFetch(input, init); + const url = typeof input === "string" ? input : input.toString(); + + // Capture auth server metadata when we see it + if ( + url.includes(".well-known/oauth-authorization-server") || + url.includes(".well-known/openid-configuration") + ) { + try { + const cloned = response.clone(); + const metadata = await cloned.json(); + cachedMetadataRef.current.authServerMetadata = metadata; + // Extract auth server URL from the request URL + const authServerUrl = new URL(url); + cachedMetadataRef.current.authServerUrl = authServerUrl.origin; + } catch { + // Ignore parse errors + } + } + + return response; + }; + const provider = new DebugOAuthProvider(serverUrl); provider.setAuthCodeHandler(handleAwaitAuthCode); @@ -149,15 +189,17 @@ export function AuthDebuggerFlow({ const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(initResponse); - // Step 2: Run auth() - middleware captures all requests - let result = await auth(provider, { + // 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, we've already gotten the code via handleAwaitAuthCode + // 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) { @@ -167,13 +209,36 @@ export function AuthDebuggerFlow({ provider.clearPendingAuthCode(); - result = await auth(provider, { - serverUrl, - resourceMetadataUrl, - scope, + 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") { From 60bdaed930397464ff1a33409d1cb426f6be00c0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 19:05:56 +0000 Subject: [PATCH 06/16] feat(auth-debugger): add quick flow mode and opener pattern Adds three enhancements to the auth debugger: 1. Keep request history visible after flow completes - Flow now shows 'complete' state with success indicator - All steps remain expandable to review request/response details - Added 'Close' button to reset and start a new flow 2. Quick Flow mode - New 'Quick Flow' button runs the entire OAuth flow without pausing - Still captures and displays all requests for review - Steps appear as they complete with spinning indicator 3. Opener pattern for automatic auth code capture - Auth URL now opens in popup window instead of new tab - OAuthDebugCallback detects if opened as popup (via window.opener) - If popup, sends code back via postMessage and auto-closes - Falls back to manual code paste if popup fails --- client/src/components/AuthDebugger.tsx | 94 ++++++++++++------- client/src/components/AuthDebuggerFlow.tsx | 58 +++++++++++- client/src/components/OAuthDebugCallback.tsx | 17 ++++ .../__tests__/AuthDebugger.test.tsx | 32 ++++--- 4 files changed, 152 insertions(+), 49 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index cc02682c1..4bd59b60d 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -61,7 +61,8 @@ const AuthDebugger = ({ authState, updateAuthState, }: AuthDebuggerProps) => { - const [showDebugFlow, setShowDebugFlow] = useState(false); + const [flowMode, setFlowMode] = useState<"debug" | "quick" | null>(null); + const [flowComplete, setFlowComplete] = useState(false); // Check for existing tokens on mount useEffect(() => { @@ -84,42 +85,47 @@ const AuthDebugger = ({ } }, [serverUrl, updateAuthState, authState.oauthTokens]); - const startDebugFlow = useCallback(() => { - if (!serverUrl) { + const startFlow = useCallback( + (mode: "debug" | "quick") => { + if (!serverUrl) { + updateAuthState({ + statusMessage: { + type: "error", + message: + "Please enter a server URL in the sidebar before authenticating", + }, + }); + return; + } + updateAuthState({ - statusMessage: { - type: "error", - message: - "Please enter a server URL in the sidebar before authenticating", - }, + statusMessage: null, + latestError: null, }); - return; - } + setFlowComplete(false); + setFlowMode(mode); + }, + [serverUrl, updateAuthState], + ); - updateAuthState({ - statusMessage: null, - latestError: null, - }); - setShowDebugFlow(true); - }, [serverUrl, updateAuthState]); + const startDebugFlow = useCallback(() => startFlow("debug"), [startFlow]); + const startQuickFlow = useCallback(() => startFlow("quick"), [startFlow]); const handleFlowComplete = useCallback( (tokens: OAuthTokens) => { updateAuthState({ oauthTokens: tokens, oauthStep: "complete", - statusMessage: { - type: "success", - message: "Authentication completed successfully", - }, }); - setShowDebugFlow(false); + // Keep the flow visible but mark as complete + setFlowComplete(true); }, [updateAuthState], ); const handleFlowCancel = useCallback(() => { - setShowDebugFlow(false); + setFlowMode(null); + setFlowComplete(false); updateAuthState({ statusMessage: { type: "info", @@ -130,7 +136,8 @@ const AuthDebugger = ({ const handleFlowError = useCallback( (error: Error) => { - setShowDebugFlow(false); + setFlowMode(null); + setFlowComplete(false); updateAuthState({ latestError: error, statusMessage: { @@ -155,7 +162,8 @@ const AuthDebugger = ({ message: "OAuth tokens cleared successfully", }, }); - setShowDebugFlow(false); + setFlowMode(null); + setFlowComplete(false); // Clear success message after 3 seconds setTimeout(() => { @@ -164,6 +172,11 @@ const AuthDebugger = ({ } }, [serverUrl, updateAuthState]); + const handleNewFlow = useCallback(() => { + setFlowMode(null); + setFlowComplete(false); + }, []); + return (
@@ -200,28 +213,45 @@ const AuthDebugger = ({
)} -
- + + + + {flowComplete && ( + + )}

- The debug flow lets you step through each OAuth request one at - a time, inspecting headers and responses. + Debug Flow: Step through each OAuth request + one at a time. Quick Flow: Run the entire + flow automatically.

- {showDebugFlow && ( + {flowMode && ( void; onCancel: () => void; onError: (error: Error) => void; @@ -31,6 +32,7 @@ type FlowState = "running" | "waiting_continue" | "waiting_code" | "complete"; export function AuthDebuggerFlow({ serverUrl, + quickMode = false, onComplete, onCancel, onError, @@ -50,6 +52,7 @@ export function AuthDebuggerFlow({ 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 @@ -61,19 +64,27 @@ export function AuthDebuggerFlow({ resource?: URL; }>({}); - // Handler for middleware callback - pauses until Continue clicked + // Handler for middleware callback - pauses until Continue clicked (unless quickMode) const handleRequestComplete = useCallback( async (entry: DebugRequestResponse) => { + // Always add to completed steps + setCompletedSteps((prev) => [...prev, entry]); + + if (quickMode) { + // Quick mode: don't pause, just continue + return; + } + + // Debug mode: pause and wait for Continue 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 @@ -265,11 +276,40 @@ export function AuthDebuggerFlow({ 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); - window.open(authUrl.href, "_blank", "noopener noreferrer"); + // 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", @@ -386,6 +426,16 @@ export function AuthDebuggerFlow({ )} + + {/* Complete indicator */} + {flowState === "complete" && ( +
+ + + Authentication completed successfully + +
+ )} ); diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 95ccc0760..d05682aa5 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -30,6 +30,23 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { isProcessed = true; const params = parseOAuthCallbackParams(window.location.search); + + // Check if we're in a popup opened by the auth debugger + // If so, send the code via postMessage and close + if (window.opener && params.successful && "code" in params) { + try { + window.opener.postMessage( + { type: "oauth-callback", code: params.code }, + window.location.origin, + ); + // Close the popup after sending the message + window.close(); + return; + } catch { + // Fall through to normal handling if postMessage fails + } + } + if (!params.successful) { const errorMsg = generateOAuthErrorDescription(params); onConnect({ errorMsg }); diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 5afbf4757..8dcb0a4c7 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -111,14 +111,19 @@ describe("AuthDebugger", () => { expect(onBack).toHaveBeenCalled(); }); - it("should show Start Debug Flow button when no tokens exist", async () => { + it("should show Debug Flow and Quick Flow buttons when no tokens exist", async () => { await act(async () => { renderAuthDebugger(); }); - expect(screen.getByText("Start Debug Flow")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Debug Flow" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Quick Flow" }), + ).toBeInTheDocument(); }); - it("should show Refresh Token button when tokens exist", async () => { + it("should show Debug Flow and Quick Flow buttons when tokens exist", async () => { await act(async () => { renderAuthDebugger({ authState: { @@ -127,7 +132,12 @@ describe("AuthDebugger", () => { }, }); }); - expect(screen.getByText("Refresh Token")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Debug Flow" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Quick Flow" }), + ).toBeInTheDocument(); }); }); @@ -139,7 +149,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByText("Start Debug Flow")); + fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); }); expect(updateAuthState).toHaveBeenCalledWith({ @@ -157,7 +167,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByText("Start Debug Flow")); + fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); }); expect(screen.getByTestId("mock-auth-debugger-flow")).toBeInTheDocument(); @@ -170,7 +180,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByText("Start Debug Flow")); + fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); }); await act(async () => { @@ -180,10 +190,6 @@ describe("AuthDebugger", () => { expect(updateAuthState).toHaveBeenCalledWith({ oauthTokens: mockOAuthTokens, oauthStep: "complete", - statusMessage: { - type: "success", - message: "Authentication completed successfully", - }, }); }); @@ -194,7 +200,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByText("Start Debug Flow")); + fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); }); await act(async () => { @@ -216,7 +222,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByText("Start Debug Flow")); + fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); }); await act(async () => { From 4f1632fddfa1aaf72280b98a58ce944a4e1932a0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 19:12:13 +0000 Subject: [PATCH 07/16] fix(auth-debugger): handle CORS preflight failures gracefully When the initial POST request fails due to CORS preflight rejection (e.g., server returns 401 on OPTIONS request), we now: 1. Show the failed request as a step with error details 2. Note that we're continuing without WWW-Authenticate metadata 3. Proceed with OAuth discovery using default well-known URLs This allows authentication to work with servers that don't properly handle CORS for unauthenticated requests, like Azure Databricks. --- client/src/components/AuthDebuggerFlow.tsx | 89 +++++++++++++++------- 1 file changed, 63 insertions(+), 26 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index 4f5f83617..99094698d 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -150,10 +150,12 @@ export function AuthDebuggerFlow({ provider.setAuthCodeHandler(handleAwaitAuthCode); try { - // Step 1: Initialize to get 401 - let initResponse: Response; + // Step 1: Try to initialize to get 401 with WWW-Authenticate header + let resourceMetadataUrl: URL | undefined; + let scope: string | undefined; + try { - initResponse = await debugFetch(serverUrl, { + const initResponse = await debugFetch(serverUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -167,39 +169,74 @@ export function AuthDebuggerFlow({ id: 1, }), }); - } catch (fetchError) { - // Network errors (CORS, connection refused, etc.) - const message = - fetchError instanceof TypeError - ? `Network error connecting to ${serverUrl}. This could be a CORS issue or the server may not be reachable.` - : fetchError instanceof Error - ? fetchError.message - : String(fetchError); - onError(new Error(message)); - return; - } - if (initResponse.status !== 401) { - // Server may not require auth, or something else happened - if (initResponse.ok) { + 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) { + setCompletedSteps((prev) => [...prev, failedEntry]); } else { - onError( - new Error( - `Expected 401, got ${initResponse.status} ${initResponse.statusText}`, - ), - ); + setCompletedSteps((prev) => [...prev, failedEntry]); + setCurrentStep(failedEntry); + setFlowState("waiting_continue"); + await new Promise((resolve) => { + continueResolverRef.current = resolve; + }); + setCurrentStep(null); + setFlowState("running"); } - return; } - const { resourceMetadataUrl, scope } = - extractWWWAuthenticateParams(initResponse); - // 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, { From 894e3bd9b1c6f34cd84b5be02974edc871571281 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 19:14:34 +0000 Subject: [PATCH 08/16] fix(auth-debugger): show warning icon for failed requests and fix double rendering - Show yellow warning triangle (AlertTriangle) for failed requests (status 0 for network errors, or status >= 400 for HTTP errors) - Fix double rendering bug: failed step was being added to completedSteps AND set as currentStep simultaneously. Now only sets as currentStep first, then adds to completedSteps after Continue is clicked. --- client/src/components/AuthDebuggerFlow.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index 99094698d..704500f52 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -5,7 +5,13 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { Button } from "@/components/ui/button"; -import { CheckCircle2, Circle, ExternalLink, Loader2 } from "lucide-react"; +import { + AlertTriangle, + CheckCircle2, + Circle, + ExternalLink, + Loader2, +} from "lucide-react"; import { createDebugFetch, DebugRequestResponse } from "@/lib/debug-middleware"; import { DebugOAuthProvider } from "@/lib/DebugOAuthProvider"; import { @@ -224,14 +230,16 @@ export function AuthDebuggerFlow({ }; if (quickMode) { + // Quick mode: add directly to completed setCompletedSteps((prev) => [...prev, failedEntry]); } else { - setCompletedSteps((prev) => [...prev, failedEntry]); + // 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"); } @@ -500,7 +508,11 @@ function StepDisplay({ onClick={() => setExpanded(!expanded)} > {isComplete ? ( - + step.response.status === 0 || step.response.status >= 400 ? ( + + ) : ( + + ) ) : ( )} From 985389afa8b3c1cbe09890d47dde6d738935da11 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 19:15:43 +0000 Subject: [PATCH 09/16] fix(auth-debugger): fix double rendering for all steps The handleRequestComplete callback was adding to completedSteps first, then also setting currentStep - causing every step to render twice. Fixed to only add to completedSteps AFTER the user clicks Continue, matching the pattern used for the CORS failure case. --- client/src/components/AuthDebuggerFlow.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index 704500f52..a7f0b033d 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -73,20 +73,19 @@ export function AuthDebuggerFlow({ // Handler for middleware callback - pauses until Continue clicked (unless quickMode) const handleRequestComplete = useCallback( async (entry: DebugRequestResponse) => { - // Always add to completed steps - setCompletedSteps((prev) => [...prev, entry]); - if (quickMode) { - // Quick mode: don't pause, just continue + // Quick mode: add directly to completed, don't pause + setCompletedSteps((prev) => [...prev, entry]); return; } - // Debug mode: pause and wait for Continue + // 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"); }, From d67afc195dd9910d71021688e0ede931487e0a74 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 19:35:45 +0000 Subject: [PATCH 10/16] fix(auth-debugger): show failed requests as steps instead of dismissing flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two key changes: 1. debug-middleware.ts: Catch fetch failures (CORS, network errors) and show them as steps with status 0 before re-throwing the error 2. AuthDebuggerFlow.tsx: Catch SDK errors (when all discovery fails) and show as a final 'Flow Error' step instead of calling onError() which dismisses the entire flow Now when testing with a CORS-restricted server: - Initial POST failure → shown as step with warning icon - Each failed discovery request → shown as step with warning icon - Final error → shown as step, flow stays visible --- client/src/components/AuthDebuggerFlow.tsx | 37 ++++++++++++++++- client/src/lib/debug-middleware.ts | 48 ++++++++++++++++++---- 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index a7f0b033d..f00f1b0ba 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -307,7 +307,42 @@ export function AuthDebuggerFlow({ } } catch (error) { console.error("OAuth debug flow error:", error); - onError(error instanceof Error ? error : new Error(String(error))); + + // Show the final error as a step instead of dismissing the flow + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorEntry: DebugRequestResponse = { + id: crypto.randomUUID(), + label: "Flow Error", + request: { + method: "N/A", + url: serverUrl, + headers: {}, + }, + response: { + status: 0, + statusText: "Error", + headers: {}, + body: { + error: errorMessage, + note: "The OAuth flow could not complete. This may be due to CORS restrictions or server configuration.", + }, + }, + }; + + if (quickMode) { + setCompletedSteps((prev) => [...prev, errorEntry]); + setFlowState("complete"); + } else { + setCurrentStep(errorEntry); + setFlowState("waiting_continue"); + await new Promise((resolve) => { + continueResolverRef.current = resolve; + }); + setCompletedSteps((prev) => [...prev, errorEntry]); + setCurrentStep(null); + setFlowState("complete"); + } } } diff --git a/client/src/lib/debug-middleware.ts b/client/src/lib/debug-middleware.ts index 49380c0ee..e9a396c3f 100644 --- a/client/src/lib/debug-middleware.ts +++ b/client/src/lib/debug-middleware.ts @@ -39,8 +39,46 @@ export function createDebugFetch( const url = typeof input === "string" ? input : input.toString(); const method = init?.method || "GET"; - // Make the actual request - const response = await fetch(input, init); + // Parse request body if present (do this first so we can show it even on failure) + let requestBody: unknown = undefined; + if (init?.body) { + requestBody = parseBody(init.body); + } + + let response: Response; + try { + // Make the actual request + response = await fetch(input, init); + } catch (fetchError) { + // Request failed (CORS, network error, etc.) + // Show this as a failed step before re-throwing + const errorMessage = + fetchError instanceof TypeError + ? "Network error (CORS or connection failed)" + : fetchError instanceof Error + ? fetchError.message + : String(fetchError); + + const failedEntry: DebugRequestResponse = { + id: crypto.randomUUID(), + label: inferLabel(url, method), + request: { + method, + url, + headers: headersToObject(init?.headers), + body: requestBody, + }, + response: { + status: 0, + statusText: "Failed", + headers: {}, + body: { error: errorMessage }, + }, + }; + + await onComplete(failedEntry); + throw fetchError; // Re-throw so SDK can handle it + } // Clone to read body without consuming const clonedResponse = response.clone(); @@ -55,12 +93,6 @@ export function createDebugFetch( } } - // Parse request body if present - let requestBody: unknown = undefined; - if (init?.body) { - requestBody = parseBody(init.body); - } - // Build entry and wait for user to continue const entry: DebugRequestResponse = { id: crypto.randomUUID(), From d6e8570c56b8b9ece350fb5eae8e9cb9e3a3c1ee Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 19:48:44 +0000 Subject: [PATCH 11/16] feat(auth-debugger): add info/warning steps for missing metadata Adds informational steps during OAuth flow when metadata discovery fails: 1. 'Info: No PRM Found' - Shown after PRM discovery fails (.well-known/ oauth-protected-resource returns 404/error). Explains we're trying 2025-03-26 auth spec and to double-check the server URL. 2. 'Warning: No Metadata Found' - Shown before /register when no OAuth metadata was discovered. Warns this is unlikely to work and suggests checking the URL or metadata configuration. Also improves step display: - Info/Warning steps show clean message box instead of Request/Response format - Blue info icon for info steps, yellow warning icon for warning steps - No status codes shown for info/warning steps (cleaner appearance) --- client/src/components/AuthDebuggerFlow.tsx | 292 +++++++++++++++------ 1 file changed, 212 insertions(+), 80 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index f00f1b0ba..4f400d18e 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -10,6 +10,7 @@ import { CheckCircle2, Circle, ExternalLink, + Info, Loader2, } from "lucide-react"; import { createDebugFetch, DebugRequestResponse } from "@/lib/debug-middleware"; @@ -70,6 +71,19 @@ export function AuthDebuggerFlow({ 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) => { @@ -123,28 +137,109 @@ export function AuthDebuggerFlow({ if (flowStartedRef.current) return; flowStartedRef.current = true; + // Helper to create info/warning steps + function createInfoStep( + label: string, + message: string, + ): DebugRequestResponse { + return { + id: crypto.randomUUID(), + label, + request: { method: "INFO", url: "", headers: {} }, + response: { + status: 0, + statusText: "Info", + headers: {}, + body: { message }, + }, + }; + } + + function createWarningStep( + label: string, + message: string, + ): DebugRequestResponse { + return { + id: crypto.randomUUID(), + label, + request: { method: "WARNING", url: "", headers: {} }, + response: { + status: 0, + statusText: "Warning", + headers: {}, + body: { message }, + }, + }; + } + async function runDebugFlow() { const baseDebugFetch = createDebugFetch(handleRequestComplete); - // Wrap debugFetch to capture auth server metadata from responses + // Wrap debugFetch to capture metadata and inject info/warning steps const debugFetch: typeof fetch = async (input, init) => { - const response = await baseDebugFetch(input, init); const url = typeof input === "string" ? input : input.toString(); + const method = init?.method || "GET"; - // Capture auth server metadata when we see it + // 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 infoEntry = createInfoStep( + "Info: No PRM Found", + "Server does not have Protected Resource Metadata. " + + "Attempting discovery using 2025-03-26 auth spec. " + + "Double-check the server URL is correct.", + ); + await handleRequestComplete(infoEntry); + 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 warningEntry = createWarningStep( + "Warning: No Metadata Found", + "Failed to discover OAuth authorization server metadata. " + + "Attempting to register at 2025-03-26 default route (/register). " + + "This is unlikely to work - if it fails, the URL is probably wrong or metadata is missing.", + ); + await handleRequestComplete(warningEntry); + discoveryStateRef.current.shownNoMetadataWarning = true; + } + + // Make the actual request + const response = await baseDebugFetch(input, init); + + // 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") ) { - try { - const cloned = response.clone(); - const metadata = await cloned.json(); - cachedMetadataRef.current.authServerMetadata = metadata; - // Extract auth server URL from the request URL - const authServerUrl = new URL(url); - cachedMetadataRef.current.authServerUrl = authServerUrl.origin; - } catch { - // Ignore parse errors + 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 + } } } @@ -542,7 +637,16 @@ function StepDisplay({ onClick={() => setExpanded(!expanded)} > {isComplete ? ( - step.response.status === 0 || step.response.status >= 400 ? ( + step.response.status === 0 ? ( + // Status 0 = special step (info, warning, or error) + step.response.statusText === "Info" ? ( + + ) : step.response.statusText === "Warning" ? ( + + ) : ( + + ) + ) : step.response.status >= 400 ? ( ) : ( @@ -553,78 +657,106 @@ function StepDisplay({ {stepNumber}. {step.label} - - {step.response.status} {step.response.statusText} - + {/* Hide status for info/warning steps, show for HTTP requests */} + {step.request.method !== "INFO" && + step.request.method !== "WARNING" && ( + + {step.response.status} {step.response.statusText} + + )} {expanded && (
- {/* Request details */} -
- - Request - -
-
- {step.request.method}{" "} - {step.request.url} -
- {Object.keys(step.request.headers).length > 0 && ( -
-

Headers:

-
-                    {JSON.stringify(step.request.headers, null, 2)}
-                  
-
- )} - {step.request.body !== undefined && ( -
-

Body:

-
-                    {typeof step.request.body === "string"
-                      ? step.request.body
-                      : JSON.stringify(step.request.body, null, 2)}
-                  
-
- )} -
-
- - {/* Response details */} -
- - Response - -
-
- = 400 ? "text-red-600" : "text-green-600"}`} - > - {step.response.status} - {" "} - {step.response.statusText} -
- {Object.keys(step.response.headers).length > 0 && ( -
-

Headers:

-
-                    {JSON.stringify(step.response.headers, null, 2)}
-                  
-
- )} - {step.response.body !== undefined && ( -
-

Body:

-
-                    {typeof step.response.body === "string"
-                      ? step.response.body
-                      : JSON.stringify(step.response.body, null, 2)}
-                  
-
- )} + {/* Info/Warning steps: show message directly */} + {(step.request.method === "INFO" || + step.request.method === "WARNING") && ( +
+ {typeof step.response.body === "object" && + step.response.body !== null && + "message" in step.response.body + ? (step.response.body as { message: string }).message + : JSON.stringify(step.response.body)}
-
+ )} + + {/* Regular HTTP requests: show Request/Response details */} + {step.request.method !== "INFO" && + step.request.method !== "WARNING" && ( + <> + {/* Request details */} +
+ + Request + +
+
+ {step.request.method}{" "} + {step.request.url} +
+ {Object.keys(step.request.headers).length > 0 && ( +
+

Headers:

+
+                          {JSON.stringify(step.request.headers, null, 2)}
+                        
+
+ )} + {step.request.body !== undefined && ( +
+

Body:

+
+                          {typeof step.request.body === "string"
+                            ? step.request.body
+                            : JSON.stringify(step.request.body, null, 2)}
+                        
+
+ )} +
+
+ + {/* Response details */} +
+ + Response + +
+
+ = 400 ? "text-red-600" : "text-green-600"}`} + > + {step.response.status} + {" "} + {step.response.statusText} +
+ {Object.keys(step.response.headers).length > 0 && ( +
+

Headers:

+
+                          {JSON.stringify(step.response.headers, null, 2)}
+                        
+
+ )} + {step.response.body !== undefined && ( +
+

Body:

+
+                          {typeof step.response.body === "string"
+                            ? step.response.body
+                            : JSON.stringify(step.response.body, null, 2)}
+                        
+
+ )} +
+
+ + )}
)} From abdf8d93922fec2afe75a591f0b6ae1d9e179159 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 19:52:04 +0000 Subject: [PATCH 12/16] feat(auth-debugger): add spec links to info/warning messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated info/warning step messages with inline links to MCP spec: 1. 'Info: No PRM Found' - Links '2025-03-26 spec' to authorization base URL section 2. 'Warning: No Metadata Found' - Links '2025-03-26 spec' to fallbacks section, with clearer message: 'You most likely don't want this — please check the MCP URL you entered is correct.' Simplified the approach to use React fragments with inline anchor tags instead of template placeholders. --- client/src/components/AuthDebuggerFlow.tsx | 39 +++++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index 4f400d18e..2a429eabb 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -140,7 +140,7 @@ export function AuthDebuggerFlow({ // Helper to create info/warning steps function createInfoStep( label: string, - message: string, + message: React.ReactNode, ): DebugRequestResponse { return { id: crypto.randomUUID(), @@ -157,7 +157,7 @@ export function AuthDebuggerFlow({ function createWarningStep( label: string, - message: string, + message: React.ReactNode, ): DebugRequestResponse { return { id: crypto.randomUUID(), @@ -189,9 +189,19 @@ export function AuthDebuggerFlow({ ) { const infoEntry = createInfoStep( "Info: No PRM Found", - "Server does not have Protected Resource Metadata. " + - "Attempting discovery using 2025-03-26 auth spec. " + - "Double-check the server URL is correct.", + <> + Server does not have Protected Resource Metadata. Falling back to{" "} + + 2025-03-26 spec + {" "} + authorization base URL discovery. Double-check the server URL is + correct. + , ); await handleRequestComplete(infoEntry); discoveryStateRef.current.shownPrmWarning = true; @@ -206,9 +216,20 @@ export function AuthDebuggerFlow({ ) { const warningEntry = createWarningStep( "Warning: No Metadata Found", - "Failed to discover OAuth authorization server metadata. " + - "Attempting to register at 2025-03-26 default route (/register). " + - "This is unlikely to work - if it fails, the URL is probably wrong or metadata is missing.", + <> + Failed to discover OAuth authorization server metadata. Falling + back to{" "} + + 2025-03-26 spec + {" "} + server-without-metadata mode. You most likely don't want this — + please check the MCP URL you entered is correct. + , ); await handleRequestComplete(warningEntry); discoveryStateRef.current.shownNoMetadataWarning = true; @@ -681,7 +702,7 @@ function StepDisplay({ {typeof step.response.body === "object" && step.response.body !== null && "message" in step.response.body - ? (step.response.body as { message: string }).message + ? (step.response.body as { message: React.ReactNode }).message : JSON.stringify(step.response.body)} )} From 59777692cbe8af9b482bbe9f50ba810c61510b7c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 20:04:50 +0000 Subject: [PATCH 13/16] Fix Flow Error display and prevent success message after errors - Add 'error' flow state to FlowState type - Create createErrorStep helper for consistent error step creation - Change 'No PRM Found' from info (blue) to error (red) styling - Use createErrorStep for Flow Error steps instead of raw object - Add ERROR method handling in StepDisplay for red colored message box - Show red 'Authentication flow failed' message instead of green success - Remove unused createInfoStep helper --- client/src/components/AuthDebuggerFlow.tsx | 89 +++++++++++++--------- 1 file changed, 51 insertions(+), 38 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index 2a429eabb..41b66057c 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -35,7 +35,12 @@ interface AuthDebuggerFlowProps { onError: (error: Error) => void; } -type FlowState = "running" | "waiting_continue" | "waiting_code" | "complete"; +type FlowState = + | "running" + | "waiting_continue" + | "waiting_code" + | "complete" + | "error"; export function AuthDebuggerFlow({ serverUrl, @@ -137,35 +142,35 @@ export function AuthDebuggerFlow({ if (flowStartedRef.current) return; flowStartedRef.current = true; - // Helper to create info/warning steps - function createInfoStep( + // Helper to create warning/error steps + function createWarningStep( label: string, message: React.ReactNode, ): DebugRequestResponse { return { id: crypto.randomUUID(), label, - request: { method: "INFO", url: "", headers: {} }, + request: { method: "WARNING", url: "", headers: {} }, response: { status: 0, - statusText: "Info", + statusText: "Warning", headers: {}, body: { message }, }, }; } - function createWarningStep( + function createErrorStep( label: string, message: React.ReactNode, ): DebugRequestResponse { return { id: crypto.randomUUID(), label, - request: { method: "WARNING", url: "", headers: {} }, + request: { method: "ERROR", url: "", headers: {} }, response: { status: 0, - statusText: "Warning", + statusText: "Error", headers: {}, body: { message }, }, @@ -187,15 +192,15 @@ export function AuthDebuggerFlow({ discoveryStateRef.current.prmFailed && !discoveryStateRef.current.shownPrmWarning ) { - const infoEntry = createInfoStep( - "Info: No PRM Found", + const errorEntry = createErrorStep( + "Error: No PRM Found", <> Server does not have Protected Resource Metadata. Falling back to{" "} 2025-03-26 spec {" "} @@ -203,7 +208,7 @@ export function AuthDebuggerFlow({ correct. , ); - await handleRequestComplete(infoEntry); + await handleRequestComplete(errorEntry); discoveryStateRef.current.shownPrmWarning = true; } @@ -427,28 +432,21 @@ export function AuthDebuggerFlow({ // Show the final error as a step instead of dismissing the flow const errorMessage = error instanceof Error ? error.message : String(error); - const errorEntry: DebugRequestResponse = { - id: crypto.randomUUID(), - label: "Flow Error", - request: { - method: "N/A", - url: serverUrl, - headers: {}, - }, - response: { - status: 0, - statusText: "Error", - headers: {}, - body: { - error: errorMessage, - note: "The OAuth flow could not complete. This may be due to CORS restrictions or server configuration.", - }, - }, - }; + 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("complete"); + setFlowState("error"); } else { setCurrentStep(errorEntry); setFlowState("waiting_continue"); @@ -457,7 +455,7 @@ export function AuthDebuggerFlow({ }); setCompletedSteps((prev) => [...prev, errorEntry]); setCurrentStep(null); - setFlowState("complete"); + setFlowState("error"); } } } @@ -631,6 +629,16 @@ export function AuthDebuggerFlow({ )} + + {/* Error indicator */} + {flowState === "error" && ( +
+ + + Authentication flow failed + +
+ )} ); @@ -678,9 +686,10 @@ function StepDisplay({ {stepNumber}. {step.label} - {/* Hide status for info/warning steps, show for HTTP requests */} + {/* Hide status for info/warning/error steps, show for HTTP requests */} {step.request.method !== "INFO" && - step.request.method !== "WARNING" && ( + step.request.method !== "WARNING" && + step.request.method !== "ERROR" && ( {step.response.status} {step.response.statusText} @@ -689,14 +698,17 @@ function StepDisplay({ {expanded && (
- {/* Info/Warning steps: show message directly */} + {/* Info/Warning/Error steps: show message directly */} {(step.request.method === "INFO" || - step.request.method === "WARNING") && ( + step.request.method === "WARNING" || + step.request.method === "ERROR") && (
{typeof step.response.body === "object" && @@ -709,7 +721,8 @@ function StepDisplay({ {/* Regular HTTP requests: show Request/Response details */} {step.request.method !== "INFO" && - step.request.method !== "WARNING" && ( + step.request.method !== "WARNING" && + step.request.method !== "ERROR" && ( <> {/* Request details */}
From 7f0755fd5a3052f53c206038884c7b7028c70492 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 20:10:26 +0000 Subject: [PATCH 14/16] Expand warning/error steps by default - Warning and error steps are now expanded by default for visibility - Change 'No Metadata Found' to error (red) instead of warning - Remove unused createWarningStep helper --- client/src/components/AuthDebuggerFlow.tsx | 44 +++++++++------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index 41b66057c..5e1c9fe52 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -142,24 +142,7 @@ export function AuthDebuggerFlow({ if (flowStartedRef.current) return; flowStartedRef.current = true; - // Helper to create warning/error steps - function createWarningStep( - label: string, - message: React.ReactNode, - ): DebugRequestResponse { - return { - id: crypto.randomUUID(), - label, - request: { method: "WARNING", url: "", headers: {} }, - response: { - status: 0, - statusText: "Warning", - headers: {}, - body: { message }, - }, - }; - } - + // Helper to create error steps function createErrorStep( label: string, message: React.ReactNode, @@ -204,8 +187,9 @@ export function AuthDebuggerFlow({ > 2025-03-26 spec {" "} - authorization base URL discovery. Double-check the server URL is - correct. + 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); @@ -219,8 +203,8 @@ export function AuthDebuggerFlow({ !discoveryStateRef.current.oauthMetadataSuccess && !discoveryStateRef.current.shownNoMetadataWarning ) { - const warningEntry = createWarningStep( - "Warning: No Metadata Found", + const errorEntry = createErrorStep( + "Error: No Metadata Found", <> Failed to discover OAuth authorization server metadata. Falling back to{" "} @@ -228,15 +212,16 @@ export function AuthDebuggerFlow({ href="https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#fallbacks-for-servers-without-metadata-discovery" target="_blank" rel="noopener noreferrer" - className="underline text-yellow-700 hover:text-yellow-900" + className="underline text-red-600 hover:text-red-800" > 2025-03-26 spec {" "} - server-without-metadata mode. You most likely don't want this — - please check the MCP URL you entered is correct. + 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(warningEntry); + await handleRequestComplete(errorEntry); discoveryStateRef.current.shownNoMetadataWarning = true; } @@ -657,7 +642,12 @@ function StepDisplay({ isComplete, isCurrent, }: StepDisplayProps) { - const [expanded, setExpanded] = useState(isCurrent); + // Expand by default for current step, warnings, and errors + const [expanded, setExpanded] = useState( + isCurrent || + step.request.method === "WARNING" || + step.request.method === "ERROR", + ); return (
From d1cdb5422c5ae2df0ac228f93bd1c100bde2703d Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 20:17:01 +0000 Subject: [PATCH 15/16] Flip button order: Run Flow (default) and Slow Mo - 'Run Flow' is now the primary button, runs entire flow automatically - 'Slow Mo' is secondary, steps through each request one at a time - Updated button labels and help text - Updated tests to match new button names --- client/src/components/AuthDebugger.tsx | 18 +++++++------- .../__tests__/AuthDebugger.test.tsx | 24 +++++++++---------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 4bd59b60d..c6b8c9779 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -108,8 +108,8 @@ const AuthDebugger = ({ [serverUrl, updateAuthState], ); - const startDebugFlow = useCallback(() => startFlow("debug"), [startFlow]); - const startQuickFlow = useCallback(() => startFlow("quick"), [startFlow]); + const startRunFlow = useCallback(() => startFlow("quick"), [startFlow]); + const startSlowFlow = useCallback(() => startFlow("debug"), [startFlow]); const handleFlowComplete = useCallback( (tokens: OAuthTokens) => { @@ -215,18 +215,18 @@ const AuthDebugger = ({

- Debug Flow: Step through each OAuth request - one at a time. Quick Flow: Run the entire - flow automatically. + Run Flow: Run the entire OAuth flow + automatically. Slow Mo: Step through each + request one at a time.

diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 8dcb0a4c7..ce765da89 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -111,19 +111,19 @@ describe("AuthDebugger", () => { expect(onBack).toHaveBeenCalled(); }); - it("should show Debug Flow and Quick Flow buttons when no tokens exist", async () => { + it("should show Run Flow and Slow Mo buttons when no tokens exist", async () => { await act(async () => { renderAuthDebugger(); }); expect( - screen.getByRole("button", { name: "Debug Flow" }), + screen.getByRole("button", { name: "Run Flow" }), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Quick Flow" }), + screen.getByRole("button", { name: "Slow Mo" }), ).toBeInTheDocument(); }); - it("should show Debug Flow and Quick Flow buttons when tokens exist", async () => { + it("should show Run Flow and Slow Mo buttons when tokens exist", async () => { await act(async () => { renderAuthDebugger({ authState: { @@ -133,15 +133,15 @@ describe("AuthDebugger", () => { }); }); expect( - screen.getByRole("button", { name: "Debug Flow" }), + screen.getByRole("button", { name: "Run Flow" }), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Quick Flow" }), + screen.getByRole("button", { name: "Slow Mo" }), ).toBeInTheDocument(); }); }); - describe("Debug Flow", () => { + describe("Run Flow", () => { it("should show error when debug flow is started without serverUrl", async () => { const updateAuthState = jest.fn(); await act(async () => { @@ -149,7 +149,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); }); expect(updateAuthState).toHaveBeenCalledWith({ @@ -167,7 +167,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); }); expect(screen.getByTestId("mock-auth-debugger-flow")).toBeInTheDocument(); @@ -180,7 +180,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); }); await act(async () => { @@ -200,7 +200,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); }); await act(async () => { @@ -222,7 +222,7 @@ describe("AuthDebugger", () => { }); await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "Debug Flow" })); + fireEvent.click(screen.getByRole("button", { name: "Run Flow" })); }); await act(async () => { From 7290c20723e6915b21551d7f6583416a36057f18 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 26 Jan 2026 21:39:37 +0000 Subject: [PATCH 16/16] Track PRM failures when fetch throws exceptions Previously, if the PRM request failed with a network/CORS error (exception), we never set prmFailed=true because we only tracked failures from HTTP responses. Now we track failures in the catch block as well. --- client/src/components/AuthDebuggerFlow.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/client/src/components/AuthDebuggerFlow.tsx b/client/src/components/AuthDebuggerFlow.tsx index 5e1c9fe52..f3562926b 100644 --- a/client/src/components/AuthDebuggerFlow.tsx +++ b/client/src/components/AuthDebuggerFlow.tsx @@ -226,7 +226,16 @@ export function AuthDebuggerFlow({ } // Make the actual request - const response = await baseDebugFetch(input, init); + 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")) {