From 454a6098bd5fcdca5d186f1642226e2bcdc75bb6 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 29 Apr 2025 16:41:19 +0100 Subject: [PATCH 01/35] wip auth debugger --- client/src/App.tsx | 58 +- client/src/components/AuthDebugger.tsx | 600 +++++++++++++++++++ client/src/components/OAuthDebugCallback.tsx | 110 ++++ client/src/lib/constants.ts | 1 + 4 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 client/src/components/AuthDebugger.tsx create mode 100644 client/src/components/OAuthDebugCallback.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index a2dc8f0e..e666c479 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -29,17 +29,20 @@ import { useDraggablePane } from "./lib/hooks/useDraggablePane"; import { StdErrNotification } from "./lib/notificationTypes"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; import { Bell, Files, FolderTree, Hammer, Hash, + Key, MessageSquare, } from "lucide-react"; import { z } from "zod"; import "./App.css"; +import AuthDebugger from "./components/AuthDebugger"; import ConsoleTab from "./components/ConsoleTab"; import HistoryAndNotifications from "./components/History"; import PingTab from "./components/PingTab"; @@ -136,6 +139,7 @@ const App = () => { } > >([]); + const [showAuthDebugger, setShowAuthDebugger] = useState(false); const nextRequestId = useRef(0); const rootsRef = useRef([]); @@ -238,6 +242,14 @@ const App = () => { [connectMcpServer], ); + // Auto-connect to previously saved serverURL after OAuth callback + const onOAuthDebugConnect = useCallback(() => { + // setSseUrl(serverUrl); + // setTransportType("sse"); + // void connectMcpServer(); + setShowAuthDebugger(true); + }, []); + useEffect(() => { fetch(`${getMCPProxyAddress(config)}/config`) .then((response) => response.json()) @@ -482,6 +494,17 @@ const App = () => { ); } + if (window.location.pathname === "/oauth/callback/debug") { + const OAuthCallback = React.lazy( + () => import("./components/OAuthDebugCallback"), + ); + return ( + Loading...}> + + + ); + } + return (
{ Roots + + + Auth +
@@ -700,15 +727,44 @@ const App = () => { setRoots={setRoots} onRootsChange={handleRootsChange} /> + setShowAuthDebugger(false)} + /> )}
+ ) : showAuthDebugger ? ( + setShowAuthDebugger(false)} + /> ) : ( -
+

Connect to an MCP server to start inspecting

+
+

+ Need to configure authentication? +

+ +
)}
diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx new file mode 100644 index 00000000..e629995b --- /dev/null +++ b/client/src/components/AuthDebugger.tsx @@ -0,0 +1,600 @@ +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { InspectorOAuthClientProvider } from "../lib/auth"; +import { + auth, + discoverOAuthMetadata, + registerClient, + startAuthorization, + exchangeAuthorization, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { SESSION_KEYS, getServerSpecificKey } from "../lib/constants"; +import { + OAuthTokensSchema, + OAuthMetadataSchema, + OAuthMetadata, + OAuthClientInformationFull, + OAuthClientInformation, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; + +interface AuthDebuggerProps { + sseUrl: string; + bearerToken: string; + headerName: string; + setBearerToken: (token: string) => void; + setHeaderName: (headerName: string) => void; + onBack: () => void; +} + +// OAuth flow steps +type OAuthStep = + | "not_started" + | "metadata_discovery" + | "client_registration" + | "authorization_redirect" + | "authorization_code" + | "token_request" + | "complete"; + +// Enhanced version of the OAuth client provider specifically for debug flows +class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { + get redirectUrl() { + return window.location.origin + "/oauth/callback/debug"; + } +} + +const AuthDebugger = ({ + sseUrl, + bearerToken, + headerName, + setBearerToken, + setHeaderName, + onBack, +}: AuthDebuggerProps) => { + const { toast } = useToast(); + const [isInitiatingAuth, setIsInitiatingAuth] = useState(false); + const [oauthTokens, setOAuthTokens] = useState(null); + const [loading, setLoading] = useState(true); + const [localHeaderName, setLocalHeaderName] = useState(headerName); + const [localBearerToken, setLocalBearerToken] = useState(bearerToken); + const [oauthStep, setOAuthStep] = useState("not_started"); + const [oauthMetadata, setOAuthMetadata] = useState( + null, + ); + const [oauthClientInfo, setOAuthClientInfo] = useState< + OAuthClientInformationFull | OAuthClientInformation | null + >(null); + const [authorizationUrl, setAuthorizationUrl] = useState(null); + const [oauthFlowVisible, setOAuthFlowVisible] = useState(false); + const [authorizationCode, setAuthorizationCode] = useState(""); + const [latestError, setLatestError] = useState(null); + // Load OAuth tokens on component mount + useEffect(() => { + const loadOAuthTokens = async () => { + try { + if (sseUrl) { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl); + const tokens = sessionStorage.getItem(key); + if (tokens) { + const parsedTokens = await OAuthTokensSchema.parseAsync( + JSON.parse(tokens), + ); + setOauthTokens(parsedTokens); + setOAuthStep("complete"); + } + } + } catch (error) { + console.error("Error loading OAuth tokens:", error); + } finally { + setLoading(false); + } + }; + + loadOAuthTokens(); + }, [sseUrl]); // Check for debug callback code + + useEffect(() => { + const loadSessionInfo = async () => { + const debugCode = sessionStorage.getItem(SESSION_KEYS.DEBUG_CODE); + if (debugCode && sseUrl) { + // We've returned from a debug OAuth callback with a code + setAuthorizationCode(debugCode); + setOAuthFlowVisible(true); + + // Set the OAuth flow step to token request + setOAuthStep("token_request"); + const provider = new DebugInspectorOAuthClientProvider(sseUrl); + setOAuthClientInfo((await provider.clientInformation()) || null); + + // Now that we've processed it, clear the debug code + sessionStorage.removeItem(SESSION_KEYS.DEBUG_CODE); + } + }; + + loadSessionInfo(); + }, [sseUrl]); + + const startOAuthFlow = () => { + if (!sseUrl) { + toast({ + title: "Error", + description: + "Please enter a server URL in the sidebar before authenticating", + variant: "destructive", + }); + return; + } + + setOAuthFlowVisible(true); + setOAuthStep("not_started"); + setAuthorizationUrl(null); + }; + + const proceedToNextStep = async () => { + if (!sseUrl) return; + const provider = new DebugInspectorOAuthClientProvider(sseUrl); + + try { + setIsInitiatingAuth(true); + + if (oauthStep === "not_started") { + setOAuthStep("metadata_discovery"); + const metadata = await discoverOAuthMetadata(sseUrl); + if (!metadata) { + toast({ + title: "Error", + description: "Failed to discover OAuth metadata", + variant: "destructive", + }); + return; + } + const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); + setOAuthMetadata(parsedMetadata); + } else if (oauthStep === "metadata_discovery") { + setOAuthStep("client_registration"); + + const fullInformation = await registerClient(sseUrl, { + metadata: oauthMetadata, + clientMetadata: provider.clientMetadata, + }); + + provider.saveClientInformation(fullInformation); + } else if (oauthStep === "client_registration") { + setOAuthStep("authorization_redirect"); + // This custom implementation captures the OAuth flow step by step + // First, get or register the client + try { + const clientInformation = await provider.clientInformation(); + const { authorizationUrl, codeVerifier } = await startAuthorization( + sseUrl, + { + metadata: oauthMetadata, + clientInformation, + redirectUrl: provider.redirectUrl, + }, + ); + + provider.saveCodeVerifier(codeVerifier); + // Save this so the debug callback knows what to do + // await sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl); + setAuthorizationUrl(authorizationUrl.toString()); + // await provider.redirectToAuthorization(authorizationUrl); + setOAuthStep("authorization_code"); + + // await auth(serverAuthProvider, { serverUrl: sseUrl }); + } catch (error) { + console.error("OAuth flow step error:", error); + toast({ + title: "OAuth Setup Error", + description: `Failed to complete OAuth setup: ${error instanceof Error ? error.message : String(error)}`, + variant: "destructive", + }); + } + } else if (oauthStep === "authorization_code") { + // This is after we enter the code. + setOAuthStep("token_request"); + } else if (oauthStep === "token_request") { + const codeVerifier = provider.codeVerifier(); + const clientInformation = await provider.clientInformation(); + + // const clientInformation = await provider.clientInformation(); + const tokens = await exchangeAuthorization(sseUrl, { + metadata: oauthMetadata, + clientInformation, + authorizationCode, + codeVerifier, + redirectUri: provider.redirectUrl, + }); + + provider.saveTokens(tokens); + setOAuthTokens(tokens); + setOAuthStep("complete"); + } + } catch (error) { + console.error("OAuth flow error:", error); + setLatestError(error instanceof Error ? error : new Error(String(error))); + + toast({ + title: "OAuth Flow Error", + description: `Error in OAuth flow: ${error instanceof Error ? error.message : String(error)}`, + variant: "destructive", + }); + } finally { + setIsInitiatingAuth(false); + } + }; + + const handleStartOAuth = async () => { + if (!sseUrl) { + toast({ + title: "Error", + description: + "Please enter a server URL in the sidebar before authenticating", + variant: "destructive", + }); + return; + } + + setIsInitiatingAuth(true); + try { + // Create an auth provider with the current server URL + const serverAuthProvider = new DebugInspectorOAuthClientProvider(sseUrl); + + // Start the OAuth flow with immediate redirect + await auth(serverAuthProvider, { serverUrl: sseUrl }); + + // This code may not run if redirected to OAuth provider + toast({ + title: "Authenticating", + description: "Starting OAuth authentication process...", + }); + } catch (error) { + console.error("OAuth initialization error:", error); + toast({ + title: "OAuth Error", + description: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, + variant: "destructive", + }); + } finally { + setIsInitiatingAuth(false); + } + }; + + const handleClearOAuth = () => { + if (sseUrl) { + const serverAuthProvider = new DebugInspectorOAuthClientProvider(sseUrl); + serverAuthProvider.clear(); + setOAuthTokens(null); + setOAuthStep("not_started"); + setOAuthFlowVisible(false); + setLatestError(null); + setAuthorizationCode(""); + toast({ + title: "Success", + description: "OAuth tokens cleared successfully", + }); + } + }; + + const handleSaveManualAuth = () => { + setBearerToken(localBearerToken); + setHeaderName(localHeaderName); + toast({ + title: "Settings Saved", + description: + "Your authentication settings have been saved for the next connection", + }); + }; + + const getOAuthStatus = () => { + if (!oauthTokens) return "Not authenticated"; + + if (oauthTokens.expires_at) { + const now = Math.floor(Date.now() / 1000); + if (now > oauthTokens.expires_at) { + return "Token expired"; + } + + const timeRemaining = oauthTokens.expires_at - now; + return `Authenticated (expires in ${Math.floor(timeRemaining / 60)} minutes)`; + } + + return "Authenticated"; + }; + const renderOAuthFlow = () => { + const provider = new DebugInspectorOAuthClientProvider(sseUrl); + + const steps = [ + { + key: "not_started", + label: "Starting OAuth Flow", + metadata: null, + }, + { + key: "metadata_discovery", + label: "Metadata Discovery", + metadata: oauthMetadata && ( +
+ + Retrieved OAuth Metadata + +
+              {JSON.stringify(oauthMetadata, null, 2)}
+            
+
+ ), + }, + { + key: "client_registration", + label: "Client Registration", + metadata: ( +
+ + Registered Client Information + +
+              {JSON.stringify(oauthClientInfo, null, 2)}
+            
+
+ ), + }, + { + key: "authorization_redirect", + label: "Preparing Authorization", + metadata: authorizationUrl && ( +
+

Authorization URL:

+
+

{authorizationUrl}

+ + + +
+

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

+
+ ), + }, + { + key: "authorization_code", + label: "Request Authorization and acquire authorization code", + metadata: ( +
+ +
+ setAuthorizationCode(e.target.value)} + placeholder="Enter the code from the authorization server" + className="flex h-9 w-full rounded-md border border-input 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" + /> +
+

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

+
+ ), + }, + { + key: "token_request", + label: "Token Request", + metadata: oauthMetadata && ( +
+ + Token Request Details + +
+

Token Endpoint:

+ + {oauthMetadata.token_endpoint} + + {/* TODO: break down the URL components */} +
+
+ ), + }, + { + key: "complete", + label: "Authentication Complete", + metadata: oauthTokens && ( +
+ + Access Tokens + +

Try listTools to use these credentials

+
+              {JSON.stringify(oauthTokens, null, 2)}
+            
+
+ ), + }, + ]; + + return ( +
+

OAuth Flow Progress

+

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

+ +
+ {steps.map((step, idx) => { + const currentStepIdx = steps.findIndex((s) => s.key === oauthStep); + const isComplete = idx <= currentStepIdx; + const isCurrent = step.key === oauthStep; + + return ( +
+
+ {isComplete ? ( + + ) : ( + + )} + + {step.label} + +
+ + {/* Show step metadata if current step and metadata exists */} + {(isCurrent || isComplete) && step.metadata && ( +
{step.metadata}
+ )} + + {/* Display error if current step and an error exists */} + {isCurrent && latestError && ( +
+

Error:

+

+ {latestError.message} +

+
+ )} +
+ ); + })} +
+ +
+ {oauthStep !== "complete" && ( + + )} + + {oauthStep === "authorization_redirect" && authorizationUrl && ( + + )} + + +
+
+ ); + }; + + return ( +
+
+

Authentication Settings

+ +
+ +
+
+
+

+ Configure authentication settings for your MCP server connection. +

+ +
+

OAuth Authentication

+

+ Use OAuth to securely authenticate with the MCP server. +

+ + {loading ? ( +

Loading authentication status...

+ ) : ( +
+
+ Status: + + {getOAuthStatus()} + +
+ + {oauthTokens && ( +
+

Access Token:

+
+ {oauthTokens.access_token.substring(0, 25)}... +
+
+ )} + +
+ + + + + +
+ +

+ Choose "Guided" for step-by-step instructions or "Quick" for + the standard automatic flow. +

+
+ )} +
+ + {oauthFlowVisible && renderOAuthFlow()} +
+
+
+
+ ); +}; + +export default AuthDebugger; diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx new file mode 100644 index 00000000..0a33a1bf --- /dev/null +++ b/client/src/components/OAuthDebugCallback.tsx @@ -0,0 +1,110 @@ +import { useEffect, useRef } from "react"; +import { InspectorOAuthClientProvider } from "../lib/auth"; +import { SESSION_KEYS } from "../lib/constants"; +import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; +import { useToast } from "@/hooks/use-toast.ts"; +import { + generateOAuthErrorDescription, + parseOAuthCallbackParams, +} from "@/utils/oauthUtils.ts"; + +interface OAuthCallbackProps { + onConnect: (serverUrl: string) => void; +} + +const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { + const { toast } = useToast(); + const hasProcessedRef = useRef(false); + + useEffect(() => { + const handleCallback = async () => { + // Skip if we've already processed this callback + if (hasProcessedRef.current) { + return; + } + hasProcessedRef.current = true; + + onConnect(""); + + const notifyError = (description: string) => + void toast({ + title: "OAuth Authorization Error", + description, + variant: "destructive", + }); + + const params = parseOAuthCallbackParams(window.location.search); + if (!params.successful) { + return notifyError(generateOAuthErrorDescription(params)); + } + + const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); + if (!serverUrl) { + return notifyError("Missing Server URL [DEBUG]"); + } + + onConnect(serverUrl); + + if (!params.code) { + return notifyError("Missing authorization code"); + } + + sessionStorage.setItem(SESSION_KEYS.DEBUG_CODE, params.code); + + // let result; + // try { + // // Create an auth provider with the current server URL + // const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl); + + // result = await auth(serverAuthProvider, { + // serverUrl, + // authorizationCode: params.code, + // }); + // } catch (error) { + // console.error("OAuth callback error:", error); + // return notifyError(`Unexpected error occurred: ${error}`); + // } + + // if (result !== "AUTHORIZED") { + // return notifyError( + // `Expected to be authorized after providing auth code, got: ${result}`, + // ); + // } + + // Finally, trigger auto-connect + toast({ + title: "Success", + description: "Successfully authenticated with OAuth", + variant: "default", + }); + onConnect(serverUrl); + }; + + handleCallback().finally(() => { + // Only redirect if we have the URL set, otherwise assume it was in a new tab. + if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) { + window.history.replaceState({}, document.title, "/"); + } + }); + }, [toast, onConnect]); + + return ( +
+
+

+ Please copy this authorization code and return to the Auth Debugger: +

+ + {parseOAuthCallbackParams(window.location.search).code || + "No code found"} + +

+ Close this tab and paste the code in the OAuth flow to complete + authentication. +

+
+
+ ); +}; + +export default OAuthDebugCallback; diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index a03239ae..327a7227 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -6,6 +6,7 @@ export const SESSION_KEYS = { SERVER_URL: "mcp_server_url", TOKENS: "mcp_tokens", CLIENT_INFORMATION: "mcp_client_information", + DEBUG_CODE: "mcp_code", } as const; // Generate server-specific session storage keys From cc77b848e3172f50bd42739b698d348b0174c79b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 1 May 2025 17:06:44 +0100 Subject: [PATCH 02/35] cleanup types and validation --- client/src/App.tsx | 8 -- client/src/components/AuthDebugger.tsx | 117 ++++++++++++------------- 2 files changed, 58 insertions(+), 67 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index e666c479..1e97efdd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -729,10 +729,6 @@ const App = () => { /> setShowAuthDebugger(false)} /> @@ -742,10 +738,6 @@ const App = () => { ) : showAuthDebugger ? ( setShowAuthDebugger(false)} /> ) : ( diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index e629995b..8f067e5a 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -22,10 +22,6 @@ import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; interface AuthDebuggerProps { sseUrl: string; - bearerToken: string; - headerName: string; - setBearerToken: (token: string) => void; - setHeaderName: (headerName: string) => void; onBack: () => void; } @@ -46,20 +42,43 @@ class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { } } -const AuthDebugger = ({ - sseUrl, - bearerToken, - headerName, - setBearerToken, - setHeaderName, - onBack, -}: AuthDebuggerProps) => { +const validateOAuthMetadata = ( + metadata: OAuthMetadata | null, + toast: (arg0: object) => void, +): OAuthMetadata => { + if (!metadata) { + toast({ + title: "Error", + description: "Can't advance without successfully fetching metadata", + variant: "destructive", + }); + throw new Error("OAuth metadata not found"); + } + return metadata; +}; + +const validateClientInformation = async ( + provider: DebugInspectorOAuthClientProvider, + toast: (arg0: object) => void, +): Promise => { + const clientInformation = await provider.clientInformation(); + + if (!clientInformation) { + toast({ + title: "Error", + description: "Can't advance without successful client registration", + variant: "destructive", + }); + throw new Error("OAuth client information not found"); + } + return clientInformation; +}; + +const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { const { toast } = useToast(); const [isInitiatingAuth, setIsInitiatingAuth] = useState(false); const [oauthTokens, setOAuthTokens] = useState(null); const [loading, setLoading] = useState(true); - const [localHeaderName, setLocalHeaderName] = useState(headerName); - const [localBearerToken, setLocalBearerToken] = useState(bearerToken); const [oauthStep, setOAuthStep] = useState("not_started"); const [oauthMetadata, setOAuthMetadata] = useState( null, @@ -82,7 +101,7 @@ const AuthDebugger = ({ const parsedTokens = await OAuthTokensSchema.parseAsync( JSON.parse(tokens), ); - setOauthTokens(parsedTokens); + setOAuthTokens(parsedTokens); setOAuthStep("complete"); } } @@ -154,24 +173,30 @@ const AuthDebugger = ({ const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); setOAuthMetadata(parsedMetadata); } else if (oauthStep === "metadata_discovery") { + const metadata = validateOAuthMetadata(oauthMetadata, toast); + setOAuthStep("client_registration"); const fullInformation = await registerClient(sseUrl, { - metadata: oauthMetadata, + metadata, clientMetadata: provider.clientMetadata, }); provider.saveClientInformation(fullInformation); } else if (oauthStep === "client_registration") { + const metadata = validateOAuthMetadata(oauthMetadata, toast); + const clientInformation = await validateClientInformation( + provider, + toast, + ); setOAuthStep("authorization_redirect"); // This custom implementation captures the OAuth flow step by step // First, get or register the client try { - const clientInformation = await provider.clientInformation(); const { authorizationUrl, codeVerifier } = await startAuthorization( sseUrl, { - metadata: oauthMetadata, + metadata, clientInformation, redirectUrl: provider.redirectUrl, }, @@ -194,15 +219,27 @@ const AuthDebugger = ({ }); } } else if (oauthStep === "authorization_code") { - // This is after we enter the code. + if (!authorizationCode || authorizationCode.trim() === "") { + toast({ + title: "Error", + description: "You need to provide an authorization code", + variant: "destructive", + }); + return; + } + // We have a code, continue to token request setOAuthStep("token_request"); } else if (oauthStep === "token_request") { const codeVerifier = provider.codeVerifier(); - const clientInformation = await provider.clientInformation(); + const metadata = validateOAuthMetadata(oauthMetadata, toast); + const clientInformation = await validateClientInformation( + provider, + toast, + ); // const clientInformation = await provider.clientInformation(); const tokens = await exchangeAuthorization(sseUrl, { - metadata: oauthMetadata, + metadata, clientInformation, authorizationCode, codeVerifier, @@ -279,34 +316,7 @@ const AuthDebugger = ({ } }; - const handleSaveManualAuth = () => { - setBearerToken(localBearerToken); - setHeaderName(localHeaderName); - toast({ - title: "Settings Saved", - description: - "Your authentication settings have been saved for the next connection", - }); - }; - - const getOAuthStatus = () => { - if (!oauthTokens) return "Not authenticated"; - - if (oauthTokens.expires_at) { - const now = Math.floor(Date.now() / 1000); - if (now > oauthTokens.expires_at) { - return "Token expired"; - } - - const timeRemaining = oauthTokens.expires_at - now; - return `Authenticated (expires in ${Math.floor(timeRemaining / 60)} minutes)`; - } - - return "Authenticated"; - }; const renderOAuthFlow = () => { - const provider = new DebugInspectorOAuthClientProvider(sseUrl); - const steps = [ { key: "not_started", @@ -534,17 +544,6 @@ const AuthDebugger = ({

Loading authentication status...

) : (
-
- Status: - - {getOAuthStatus()} - -
- {oauthTokens && (

Access Token:

From d11f2dbd6c685025026af0bdded7d74793915949 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 1 May 2025 17:40:08 +0100 Subject: [PATCH 03/35] more cleanup --- client/src/components/OAuthDebugCallback.tsx | 44 +++++++------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 0a33a1bf..9c28f42f 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -1,7 +1,5 @@ import { useEffect, useRef } from "react"; -import { InspectorOAuthClientProvider } from "../lib/auth"; import { SESSION_KEYS } from "../lib/constants"; -import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; import { useToast } from "@/hooks/use-toast.ts"; import { generateOAuthErrorDescription, @@ -39,38 +37,20 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { } const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); + + // ServerURL isn't set, this can happen if we've opened the + // authentication request in a new tab, so we don't have the same + // session storage if (!serverUrl) { - return notifyError("Missing Server URL [DEBUG]"); + return; } - onConnect(serverUrl); - if (!params.code) { return notifyError("Missing authorization code"); } sessionStorage.setItem(SESSION_KEYS.DEBUG_CODE, params.code); - // let result; - // try { - // // Create an auth provider with the current server URL - // const serverAuthProvider = new InspectorOAuthClientProvider(serverUrl); - - // result = await auth(serverAuthProvider, { - // serverUrl, - // authorizationCode: params.code, - // }); - // } catch (error) { - // console.error("OAuth callback error:", error); - // return notifyError(`Unexpected error occurred: ${error}`); - // } - - // if (result !== "AUTHORIZED") { - // return notifyError( - // `Expected to be authorized after providing auth code, got: ${result}`, - // ); - // } - // Finally, trigger auto-connect toast({ title: "Success", @@ -81,13 +61,16 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { }; handleCallback().finally(() => { - // Only redirect if we have the URL set, otherwise assume it was in a new tab. + // Only redirect if we have the URL set, otherwise assume this was + // in a new tab if (sessionStorage.getItem(SESSION_KEYS.SERVER_URL)) { window.history.replaceState({}, document.title, "/"); } }); }, [toast, onConnect]); + const callbackParams = parseOAuthCallbackParams(window.location.search); + return (
@@ -95,8 +78,13 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { Please copy this authorization code and return to the Auth Debugger:

- {parseOAuthCallbackParams(window.location.search).code || - "No code found"} + {callbackParams.successful + ? ( + callbackParams as { + code: string; + } + ).code + : `No code found: ${callbackParams.error}, ${callbackParams.error_description}`}

Close this tab and paste the code in the OAuth flow to complete From be222058b525770f24462e9665577f999c1de130 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 1 May 2025 18:21:48 +0100 Subject: [PATCH 04/35] draft test --- .../__tests__/AuthDebugger.test.tsx | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 client/src/components/__tests__/AuthDebugger.test.tsx diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx new file mode 100644 index 00000000..d108dc00 --- /dev/null +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -0,0 +1,242 @@ +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, beforeEach, jest } from "@jest/globals"; +import AuthDebugger from "../AuthDebugger"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +// Mock OAuth data that matches the schemas +const mockOAuthTokens = { + access_token: "test_access_token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "test_refresh_token", + 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"], +}; + +const mockOAuthClientInfo = { + client_id: "test_client_id", + client_secret: "test_client_secret", + redirect_uris: ["http://localhost:3000/oauth/callback/debug"], +}; + +// Mock the toast hook +const mockToast = jest.fn(); +jest.mock("@/hooks/use-toast", () => ({ + useToast: () => ({ + toast: mockToast, + }), +})); + +// Mock MCP SDK functions +jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ + auth: jest.fn(), + discoverOAuthMetadata: jest.fn(), + registerClient: jest.fn(), + startAuthorization: jest.fn(), + exchangeAuthorization: jest.fn(), +})); + +// Import mocked functions +import { + auth as mockAuth, + discoverOAuthMetadata as mockDiscoverOAuthMetadata, + registerClient as mockRegisterClient, + startAuthorization as mockStartAuthorization, + exchangeAuthorization as mockExchangeAuthorization, +} from "@modelcontextprotocol/sdk/client/auth.js"; + +// Mock Session Storage +const sessionStorageMock = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), +}; +Object.defineProperty(window, 'sessionStorage', { + value: sessionStorageMock, +}); + +// Mock the location.origin +Object.defineProperty(window, 'location', { + value: { + origin: 'http://localhost:3000', + }, +}); + +describe("AuthDebugger", () => { + const defaultProps = { + sseUrl: "https://example.com", + onBack: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + sessionStorageMock.getItem.mockReturnValue(null); + (mockDiscoverOAuthMetadata as jest.Mock).mockResolvedValue(mockOAuthMetadata); + (mockRegisterClient as jest.Mock).mockResolvedValue(mockOAuthClientInfo); + (mockStartAuthorization as jest.Mock).mockResolvedValue({ + authorizationUrl: new URL("https://oauth.example.com/authorize"), + codeVerifier: "test_verifier" + }); + (mockExchangeAuthorization as jest.Mock).mockResolvedValue(mockOAuthTokens); + }); + + const renderAuthDebugger = (props = {}) => { + return render( + + + + ); + }; + + describe("Initial Rendering", () => { + it("should render the component with correct title", async () => { + await act(async () => { + renderAuthDebugger(); + }); + expect(screen.getByText("Authentication Settings")).toBeInTheDocument(); + }); + + it("should call onBack when Back button is clicked", async () => { + const onBack = jest.fn(); + await act(async () => { + renderAuthDebugger({ onBack }); + }); + 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 () => { + await act(async () => { + renderAuthDebugger(); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument(); + }); + + it("should show error when OAuth flow is started without sseUrl", async () => { + await act(async () => { + renderAuthDebugger({ sseUrl: "" }); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + expect(mockToast).toHaveBeenCalledWith({ + title: "Error", + description: "Please enter a server URL in the sidebar before authenticating", + variant: "destructive", + }); + }); + }); + + 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; + }); + + await act(async () => { + renderAuthDebugger(); + }); + + await waitFor(() => { + expect(screen.getByText(/Access Token:/)).toBeInTheDocument(); + }); + }); + + it("should handle errors loading OAuth tokens from session storage", async () => { + // Mock console to avoid cluttering test output + const originalError = console.error; + console.error = jest.fn(); + + // Mock getItem to return invalid JSON for tokens + sessionStorageMock.getItem.mockImplementation((key) => { + if (key === "[https://example.com] mcp_tokens") { + return "invalid json"; + } + return null; + }); + + await act(async () => { + renderAuthDebugger(); + }); + + // Component should still render despite the error + expect(screen.getByText("Authentication Settings")).toBeInTheDocument(); + + // Restore console.error + console.error = originalError; + }); + }); + + describe("OAuth State Management", () => { + it("should clear OAuth state when Clear button is clicked", async () => { + // 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(); + }); + + await act(async () => { + fireEvent.click(screen.getByText("Clear OAuth State")); + }); + + expect(mockToast).toHaveBeenCalledWith({ + title: "Success", + description: "OAuth tokens cleared successfully", + }); + + // Verify session storage was cleared + expect(sessionStorageMock.removeItem).toHaveBeenCalled(); + }); + }); + + describe("OAuth Flow Steps", () => { + it("should handle OAuth flow step progression", async () => { + await act(async () => { + renderAuthDebugger(); + }); + + // Start guided flow + await act(async () => { + fireEvent.click(screen.getByText("Guided OAuth Flow")); + }); + + // 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(mockDiscoverOAuthMetadata).toHaveBeenCalledWith("https://example.com"); + }); + }); +}); \ No newline at end of file From 19f01e1050b14ab4a6c6fb45704e7549c7424f88 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 7 May 2025 17:23:20 +0100 Subject: [PATCH 05/35] wip clean up some --- client/src/App.tsx | 63 ++++++------ client/src/components/AuthDebugger.tsx | 97 ++++++++++++------- client/src/components/OAuthDebugCallback.tsx | 31 +++--- .../__tests__/AuthDebugger.test.tsx | 3 +- client/src/lib/constants.ts | 4 + 5 files changed, 115 insertions(+), 83 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 1e97efdd..463131a0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -36,13 +36,12 @@ import { FolderTree, Hammer, Hash, - Key, MessageSquare, } from "lucide-react"; import { z } from "zod"; import "./App.css"; -import AuthDebugger from "./components/AuthDebugger"; +const AuthDebugger = React.lazy(() => import("./components/AuthDebugger")); import ConsoleTab from "./components/ConsoleTab"; import HistoryAndNotifications from "./components/History"; import PingTab from "./components/PingTab"; @@ -139,7 +138,7 @@ const App = () => { } > >([]); - const [showAuthDebugger, setShowAuthDebugger] = useState(false); + const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); const nextRequestId = useRef(0); const rootsRef = useRef([]); @@ -244,10 +243,7 @@ const App = () => { // Auto-connect to previously saved serverURL after OAuth callback const onOAuthDebugConnect = useCallback(() => { - // setSseUrl(serverUrl); - // setTransportType("sse"); - // void connectMcpServer(); - setShowAuthDebugger(true); + setIsAuthDebuggerVisible(true); }, []); useEffect(() => { @@ -483,26 +479,35 @@ const App = () => { setStdErrNotifications([]); }; - if (window.location.pathname === "/oauth/callback") { - const OAuthCallback = React.lazy( - () => import("./components/OAuthCallback"), - ); + // Helper component for rendering the AuthDebugger + const AuthDebuggerWrapper = () => ( + Loading...

}> + setIsAuthDebuggerVisible(false)} + /> + + ); + + // Helper function to render OAuth callback components + const renderOAuthCallback = (path: string, onConnect: (serverUrl: string) => void) => { + const Component = path === "/oauth/callback" + ? React.lazy(() => import("./components/OAuthCallback")) + : React.lazy(() => import("./components/OAuthDebugCallback")); + return ( Loading...
}> - + ); + }; + + if (window.location.pathname === "/oauth/callback") { + return renderOAuthCallback(window.location.pathname, onOAuthConnect); } if (window.location.pathname === "/oauth/callback/debug") { - const OAuthCallback = React.lazy( - () => import("./components/OAuthDebugCallback"), - ); - return ( - Loading...
}> - - - ); + return renderOAuthCallback(window.location.pathname, onOAuthDebugConnect); } return ( @@ -592,10 +597,6 @@ const App = () => { Roots - - - Auth -
@@ -727,19 +728,13 @@ const App = () => { setRoots={setRoots} onRootsChange={handleRootsChange} /> - setShowAuthDebugger(false)} - /> + )}
- ) : showAuthDebugger ? ( - setShowAuthDebugger(false)} - /> + ) : isAuthDebuggerVisible ? ( + ) : (

@@ -752,7 +747,7 @@ const App = () => { diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 8f067e5a..8265c073 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -20,6 +20,9 @@ import { } from "@modelcontextprotocol/sdk/shared/auth.js"; import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; +// Type for the toast function from the useToast hook +type ToastFunction = ReturnType["toast"]; + interface AuthDebuggerProps { sseUrl: string; onBack: () => void; @@ -37,14 +40,14 @@ type OAuthStep = // Enhanced version of the OAuth client provider specifically for debug flows class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { - get redirectUrl() { - return window.location.origin + "/oauth/callback/debug"; + get redirectUrl(): string { + return `${window.location.origin}/oauth/callback/debug`; } } const validateOAuthMetadata = ( metadata: OAuthMetadata | null, - toast: (arg0: object) => void, + toast: ToastFunction, ): OAuthMetadata => { if (!metadata) { toast({ @@ -59,7 +62,7 @@ const validateOAuthMetadata = ( const validateClientInformation = async ( provider: DebugInspectorOAuthClientProvider, - toast: (arg0: object) => void, + toast: ToastFunction, ): Promise => { const clientInformation = await provider.clientInformation(); @@ -115,25 +118,29 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { loadOAuthTokens(); }, [sseUrl]); // Check for debug callback code + // Check for debug callback code and load client info useEffect(() => { - const loadSessionInfo = async () => { - const debugCode = sessionStorage.getItem(SESSION_KEYS.DEBUG_CODE); - if (debugCode && sseUrl) { - // We've returned from a debug OAuth callback with a code - setAuthorizationCode(debugCode); - setOAuthFlowVisible(true); - - // Set the OAuth flow step to token request - setOAuthStep("token_request"); - const provider = new DebugInspectorOAuthClientProvider(sseUrl); - setOAuthClientInfo((await provider.clientInformation()) || null); - - // Now that we've processed it, clear the debug code - sessionStorage.removeItem(SESSION_KEYS.DEBUG_CODE); - } - }; + const debugCode = sessionStorage.getItem(SESSION_KEYS.DEBUG_CODE); + if (debugCode && sseUrl) { + // We've returned from a debug OAuth callback with a code + setAuthorizationCode(debugCode); + setOAuthFlowVisible(true); + setOAuthStep("token_request"); + + // Load client info asynchronously + const provider = new DebugInspectorOAuthClientProvider(sseUrl); + provider + .clientInformation() + .then((info) => { + setOAuthClientInfo(info || null); + }) + .catch((error) => { + console.error("Failed to load client information:", error); + }); - loadSessionInfo(); + // Now that we've processed it, clear the debug code + sessionStorage.removeItem(SESSION_KEYS.DEBUG_CODE); + } }, [sseUrl]); const startOAuthFlow = () => { @@ -177,12 +184,23 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { setOAuthStep("client_registration"); + const clientMetadata = provider.clientMetadata; + // Add all supported scopes to client registration. + // This is the maximal set of scopes the client can request, the + // scope of the actual token is specified below + if (metadata.scopes_supported) { + // TODO: add this to schema + clientMetadata["scope"] = metadata.scopes_supported.join(" "); + } + const fullInformation = await registerClient(sseUrl, { metadata, - clientMetadata: provider.clientMetadata, + clientMetadata, }); provider.saveClientInformation(fullInformation); + // save it here to be more convenient for display + setOAuthClientInfo(fullInformation); } else if (oauthStep === "client_registration") { const metadata = validateOAuthMetadata(oauthMetadata, toast); const clientInformation = await validateClientInformation( @@ -190,8 +208,6 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { toast, ); setOAuthStep("authorization_redirect"); - // This custom implementation captures the OAuth flow step by step - // First, get or register the client try { const { authorizationUrl, codeVerifier } = await startAuthorization( sseUrl, @@ -199,17 +215,25 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { metadata, clientInformation, redirectUrl: provider.redirectUrl, + // TODO: fix this once SDK PR is merged + // scope: metadata.scopes_supported, }, ); provider.saveCodeVerifier(codeVerifier); - // Save this so the debug callback knows what to do - // await sessionStorage.setItem(SESSION_KEYS.SERVER_URL, sseUrl); - setAuthorizationUrl(authorizationUrl.toString()); - // await provider.redirectToAuthorization(authorizationUrl); - setOAuthStep("authorization_code"); - // await auth(serverAuthProvider, { serverUrl: sseUrl }); + // TODO: remove this once scope is valid parameter above + // Modify the authorization URL to include all supported scopes + if (metadata.scopes_supported) { + //Add all supported scopes to the authorization URL + const url = new URL(authorizationUrl.toString()); + url.searchParams.set("scope", metadata.scopes_supported.join(" ")); + setAuthorizationUrl(url.toString()); + } else { + setAuthorizationUrl(authorizationUrl.toString()); + } + + setOAuthStep("authorization_code"); } catch (error) { console.error("OAuth flow step error:", error); toast({ @@ -237,7 +261,6 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { toast, ); - // const clientInformation = await provider.clientInformation(); const tokens = await exchangeAuthorization(sseUrl, { metadata, clientInformation, @@ -308,6 +331,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { setOAuthStep("not_started"); setOAuthFlowVisible(false); setLatestError(null); + setOAuthClientInfo(null); setAuthorizationCode(""); toast({ title: "Success", @@ -329,7 +353,8 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { metadata: oauthMetadata && (

- Retrieved OAuth Metadata + Retrieved OAuth Metadata from {sseUrl} + /.well-known/oauth-authorization-server
               {JSON.stringify(oauthMetadata, null, 2)}
@@ -364,6 +389,8 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {
                 target="_blank"
                 rel="noopener noreferrer"
                 className="flex items-center text-blue-500 hover:text-blue-700"
+                aria-label="Open authorization URL in new tab"
+                title="Open authorization URL"
               >
                 
               
@@ -428,7 +455,11 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {
             
               Access Tokens
             
-            

Try listTools to use these credentials

+

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

               {JSON.stringify(oauthTokens, null, 2)}
             
diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 9c28f42f..6ebf91b6 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { SESSION_KEYS } from "../lib/constants"; import { useToast } from "@/hooks/use-toast.ts"; import { @@ -12,17 +12,16 @@ interface OAuthCallbackProps { const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { const { toast } = useToast(); - const hasProcessedRef = useRef(false); useEffect(() => { + let isProcessed = false; + const handleCallback = async () => { // Skip if we've already processed this callback - if (hasProcessedRef.current) { + if (isProcessed) { return; } - hasProcessedRef.current = true; - - onConnect(""); + isProcessed = true; const notifyError = (description: string) => void toast({ @@ -42,6 +41,8 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { // authentication request in a new tab, so we don't have the same // session storage if (!serverUrl) { + // If there's no server URL, we're likely in a new tab + // Just display the code for manual copying return; } @@ -51,12 +52,14 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { sessionStorage.setItem(SESSION_KEYS.DEBUG_CODE, params.code); - // Finally, trigger auto-connect + // Finally, trigger navigation back to auth debugger toast({ title: "Success", - description: "Successfully authenticated with OAuth", + description: "Authorization code received. Please return to the Auth Debugger.", variant: "default", }); + + // Call onConnect to navigate back to the auth debugger onConnect(serverUrl); }; @@ -67,6 +70,10 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { window.history.replaceState({}, document.title, "/"); } }); + + return () => { + isProcessed = true; + }; }, [toast, onConnect]); const callbackParams = parseOAuthCallbackParams(window.location.search); @@ -78,12 +85,8 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { Please copy this authorization code and return to the Auth Debugger:

- {callbackParams.successful - ? ( - callbackParams as { - code: string; - } - ).code + {callbackParams.successful && 'code' in callbackParams + ? callbackParams.code : `No code found: ${callbackParams.error}, ${callbackParams.error_description}`}

diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index d108dc00..76196cae 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -44,9 +44,8 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ exchangeAuthorization: jest.fn(), })); -// Import mocked functions +// Import mocked functions (removing unused mockAuth) import { - auth as mockAuth, discoverOAuthMetadata as mockDiscoverOAuthMetadata, registerClient as mockRegisterClient, startAuthorization as mockStartAuthorization, diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index 327a7227..206864be 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -6,6 +6,10 @@ export const SESSION_KEYS = { SERVER_URL: "mcp_server_url", TOKENS: "mcp_tokens", CLIENT_INFORMATION: "mcp_client_information", + /** + * Temporary storage for OAuth authorization code during debug flow + * Used when authentication is done in a separate tab + */ DEBUG_CODE: "mcp_code", } as const; From 6277126f83503f31cd4f7fd7a62cf71c749524bf Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 11:42:11 +0100 Subject: [PATCH 06/35] rm toasts --- client/src/App.tsx | 14 +- client/src/components/AuthDebugger.tsx | 246 ++++++++++++------------- 2 files changed, 131 insertions(+), 129 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 463131a0..cf073aa8 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -490,11 +490,15 @@ const App = () => { ); // Helper function to render OAuth callback components - const renderOAuthCallback = (path: string, onConnect: (serverUrl: string) => void) => { - const Component = path === "/oauth/callback" - ? React.lazy(() => import("./components/OAuthCallback")) - : React.lazy(() => import("./components/OAuthDebugCallback")); - + const renderOAuthCallback = ( + path: string, + onConnect: (serverUrl: string) => void, + ) => { + const Component = + path === "/oauth/callback" + ? React.lazy(() => import("./components/OAuthCallback")) + : React.lazy(() => import("./components/OAuthDebugCallback")); + return ( Loading...

}> diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 8265c073..2558c616 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { useToast } from "@/hooks/use-toast"; import { InspectorOAuthClientProvider } from "../lib/auth"; import { auth, @@ -18,10 +17,7 @@ import { OAuthClientInformation, OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; - -// Type for the toast function from the useToast hook -type ToastFunction = ReturnType["toast"]; +import { CheckCircle2, Circle, ExternalLink, AlertCircle } from "lucide-react"; interface AuthDebuggerProps { sseUrl: string; @@ -38,6 +34,14 @@ type OAuthStep = | "token_request" | "complete"; +// Message types for inline feedback +type MessageType = "success" | "error" | "info"; + +interface StatusMessage { + type: MessageType; + message: string; +} + // Enhanced version of the OAuth client provider specifically for debug flows class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { get redirectUrl(): string { @@ -45,40 +49,7 @@ class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { } } -const validateOAuthMetadata = ( - metadata: OAuthMetadata | null, - toast: ToastFunction, -): OAuthMetadata => { - if (!metadata) { - toast({ - title: "Error", - description: "Can't advance without successfully fetching metadata", - variant: "destructive", - }); - throw new Error("OAuth metadata not found"); - } - return metadata; -}; - -const validateClientInformation = async ( - provider: DebugInspectorOAuthClientProvider, - toast: ToastFunction, -): Promise => { - const clientInformation = await provider.clientInformation(); - - if (!clientInformation) { - toast({ - title: "Error", - description: "Can't advance without successful client registration", - variant: "destructive", - }); - throw new Error("OAuth client information not found"); - } - return clientInformation; -}; - const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { - const { toast } = useToast(); const [isInitiatingAuth, setIsInitiatingAuth] = useState(false); const [oauthTokens, setOAuthTokens] = useState(null); const [loading, setLoading] = useState(true); @@ -90,9 +61,15 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { OAuthClientInformationFull | OAuthClientInformation | null >(null); const [authorizationUrl, setAuthorizationUrl] = useState(null); - const [oauthFlowVisible, setOAuthFlowVisible] = useState(false); const [authorizationCode, setAuthorizationCode] = useState(""); const [latestError, setLatestError] = useState(null); + + // Status messages for inline display + const [statusMessage, setStatusMessage] = useState( + null, + ); + const [validationError, setValidationError] = useState(null); + // Load OAuth tokens on component mount useEffect(() => { const loadOAuthTokens = async () => { @@ -116,7 +93,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { }; loadOAuthTokens(); - }, [sseUrl]); // Check for debug callback code + }, [sseUrl]); // Check for debug callback code and load client info useEffect(() => { @@ -124,7 +101,6 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { if (debugCode && sseUrl) { // We've returned from a debug OAuth callback with a code setAuthorizationCode(debugCode); - setOAuthFlowVisible(true); setOAuthStep("token_request"); // Load client info asynchronously @@ -143,20 +119,40 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { } }, [sseUrl]); + const validateOAuthMetadata = ( + metadata: OAuthMetadata | null, + ): OAuthMetadata => { + if (!metadata) { + throw new Error("Can't advance without successfully fetching metadata"); + } + return metadata; + }; + + const validateClientInformation = async ( + provider: DebugInspectorOAuthClientProvider, + ): Promise => { + const clientInformation = await provider.clientInformation(); + + if (!clientInformation) { + throw new Error("Can't advance without successful client registration"); + } + return clientInformation; + }; + const startOAuthFlow = () => { if (!sseUrl) { - toast({ - title: "Error", - description: + setStatusMessage({ + type: "error", + message: "Please enter a server URL in the sidebar before authenticating", - variant: "destructive", }); return; } - setOAuthFlowVisible(true); setOAuthStep("not_started"); setAuthorizationUrl(null); + setStatusMessage(null); + setLatestError(null); }; const proceedToNextStep = async () => { @@ -165,31 +161,25 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { try { setIsInitiatingAuth(true); + setStatusMessage(null); + setLatestError(null); if (oauthStep === "not_started") { setOAuthStep("metadata_discovery"); const metadata = await discoverOAuthMetadata(sseUrl); if (!metadata) { - toast({ - title: "Error", - description: "Failed to discover OAuth metadata", - variant: "destructive", - }); - return; + throw new Error("Failed to discover OAuth metadata"); } const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); setOAuthMetadata(parsedMetadata); } else if (oauthStep === "metadata_discovery") { - const metadata = validateOAuthMetadata(oauthMetadata, toast); + const metadata = validateOAuthMetadata(oauthMetadata); setOAuthStep("client_registration"); const clientMetadata = provider.clientMetadata; // Add all supported scopes to client registration. - // This is the maximal set of scopes the client can request, the - // scope of the actual token is specified below if (metadata.scopes_supported) { - // TODO: add this to schema clientMetadata["scope"] = metadata.scopes_supported.join(" "); } @@ -199,14 +189,10 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { }); provider.saveClientInformation(fullInformation); - // save it here to be more convenient for display setOAuthClientInfo(fullInformation); } else if (oauthStep === "client_registration") { - const metadata = validateOAuthMetadata(oauthMetadata, toast); - const clientInformation = await validateClientInformation( - provider, - toast, - ); + const metadata = validateOAuthMetadata(oauthMetadata); + const clientInformation = await validateClientInformation(provider); setOAuthStep("authorization_redirect"); try { const { authorizationUrl, codeVerifier } = await startAuthorization( @@ -215,17 +201,12 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { metadata, clientInformation, redirectUrl: provider.redirectUrl, - // TODO: fix this once SDK PR is merged - // scope: metadata.scopes_supported, }, ); provider.saveCodeVerifier(codeVerifier); - // TODO: remove this once scope is valid parameter above - // Modify the authorization URL to include all supported scopes if (metadata.scopes_supported) { - //Add all supported scopes to the authorization URL const url = new URL(authorizationUrl.toString()); url.searchParams.set("scope", metadata.scopes_supported.join(" ")); setAuthorizationUrl(url.toString()); @@ -236,30 +217,21 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { setOAuthStep("authorization_code"); } catch (error) { console.error("OAuth flow step error:", error); - toast({ - title: "OAuth Setup Error", - description: `Failed to complete OAuth setup: ${error instanceof Error ? error.message : String(error)}`, - variant: "destructive", - }); + throw new Error( + `Failed to complete OAuth setup: ${error instanceof Error ? error.message : String(error)}`, + ); } } else if (oauthStep === "authorization_code") { if (!authorizationCode || authorizationCode.trim() === "") { - toast({ - title: "Error", - description: "You need to provide an authorization code", - variant: "destructive", - }); + setValidationError("You need to provide an authorization code"); return; } - // We have a code, continue to token request + setValidationError(null); setOAuthStep("token_request"); } else if (oauthStep === "token_request") { const codeVerifier = provider.codeVerifier(); - const metadata = validateOAuthMetadata(oauthMetadata, toast); - const clientInformation = await validateClientInformation( - provider, - toast, - ); + const metadata = validateOAuthMetadata(oauthMetadata); + const clientInformation = await validateClientInformation(provider); const tokens = await exchangeAuthorization(sseUrl, { metadata, @@ -276,12 +248,6 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { } catch (error) { console.error("OAuth flow error:", error); setLatestError(error instanceof Error ? error : new Error(String(error))); - - toast({ - title: "OAuth Flow Error", - description: `Error in OAuth flow: ${error instanceof Error ? error.message : String(error)}`, - variant: "destructive", - }); } finally { setIsInitiatingAuth(false); } @@ -289,34 +255,28 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { const handleStartOAuth = async () => { if (!sseUrl) { - toast({ - title: "Error", - description: + setStatusMessage({ + type: "error", + message: "Please enter a server URL in the sidebar before authenticating", - variant: "destructive", }); return; } setIsInitiatingAuth(true); + setStatusMessage(null); try { - // Create an auth provider with the current server URL const serverAuthProvider = new DebugInspectorOAuthClientProvider(sseUrl); - - // Start the OAuth flow with immediate redirect await auth(serverAuthProvider, { serverUrl: sseUrl }); - - // This code may not run if redirected to OAuth provider - toast({ - title: "Authenticating", - description: "Starting OAuth authentication process...", + setStatusMessage({ + type: "info", + message: "Starting OAuth authentication process...", }); } catch (error) { console.error("OAuth initialization error:", error); - toast({ - title: "OAuth Error", - description: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, - variant: "destructive", + setStatusMessage({ + type: "error", + message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, }); } finally { setIsInitiatingAuth(false); @@ -329,17 +289,56 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { serverAuthProvider.clear(); setOAuthTokens(null); setOAuthStep("not_started"); - setOAuthFlowVisible(false); setLatestError(null); setOAuthClientInfo(null); setAuthorizationCode(""); - toast({ - title: "Success", - description: "OAuth tokens cleared successfully", + setValidationError(null); + setStatusMessage({ + type: "success", + message: "OAuth tokens cleared successfully", }); + + // Clear success message after 3 seconds + setTimeout(() => { + setStatusMessage(null); + }, 3000); } }; + const renderStatusMessage = () => { + if (!statusMessage) return null; + + const bgColor = + statusMessage.type === "error" + ? "bg-red-50" + : statusMessage.type === "success" + ? "bg-green-50" + : "bg-blue-50"; + const textColor = + statusMessage.type === "error" + ? "text-red-700" + : statusMessage.type === "success" + ? "text-green-700" + : "text-blue-700"; + const borderColor = + statusMessage.type === "error" + ? "border-red-200" + : statusMessage.type === "success" + ? "border-green-200" + : "border-blue-200"; + + return ( +
+
+ +

{statusMessage.message}

+
+
+ ); + }; + const renderOAuthFlow = () => { const steps = [ { @@ -365,7 +364,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "client_registration", label: "Client Registration", - metadata: ( + metadata: oauthClientInfo && (
Registered Client Information @@ -417,11 +416,19 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { setAuthorizationCode(e.target.value)} + onChange={(e) => { + setAuthorizationCode(e.target.value); + setValidationError(null); + }} placeholder="Enter the code from the authorization server" - className="flex h-9 w-full rounded-md border border-input 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" + 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 ${ + validationError ? "border-red-500" : "border-input" + }`} />
+ {validationError && ( +

{validationError}

+ )}

Once you've completed authorization in the link, paste the code here. @@ -442,7 +449,6 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { {oauthMetadata.token_endpoint} - {/* TODO: break down the URL components */}

), @@ -534,16 +540,6 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { Open in New Tab )} - - ); @@ -571,6 +567,8 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { Use OAuth to securely authenticate with the MCP server.

+ {renderStatusMessage()} + {loading ? (

Loading authentication status...

) : ( @@ -619,7 +617,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { )} - {oauthFlowVisible && renderOAuthFlow()} + {renderOAuthFlow()} From ecefbddb4895d9693034ceef51fad61e5a795d57 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 12:38:34 +0100 Subject: [PATCH 07/35] consolidate state management --- client/src/components/AuthDebugger.tsx | 288 ++++++++++++++----------- 1 file changed, 160 insertions(+), 128 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 2558c616..95ab8a11 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -42,6 +42,21 @@ interface StatusMessage { message: string; } +// Single state interface to replace multiple useState calls +interface AuthDebuggerState { + isInitiatingAuth: boolean; + oauthTokens: OAuthTokens | null; + loading: boolean; + oauthStep: OAuthStep; + oauthMetadata: OAuthMetadata | null; + oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; + authorizationUrl: string | null; + authorizationCode: string; + latestError: Error | null; + statusMessage: StatusMessage | null; + validationError: string | null; +} + // Enhanced version of the OAuth client provider specifically for debug flows class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { get redirectUrl(): string { @@ -50,25 +65,25 @@ class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { } const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { - const [isInitiatingAuth, setIsInitiatingAuth] = useState(false); - const [oauthTokens, setOAuthTokens] = useState(null); - const [loading, setLoading] = useState(true); - const [oauthStep, setOAuthStep] = useState("not_started"); - const [oauthMetadata, setOAuthMetadata] = useState( - null, - ); - const [oauthClientInfo, setOAuthClientInfo] = useState< - OAuthClientInformationFull | OAuthClientInformation | null - >(null); - const [authorizationUrl, setAuthorizationUrl] = useState(null); - const [authorizationCode, setAuthorizationCode] = useState(""); - const [latestError, setLatestError] = useState(null); - - // Status messages for inline display - const [statusMessage, setStatusMessage] = useState( - null, - ); - const [validationError, setValidationError] = useState(null); + // Single state object instead of multiple useState calls + const [state, setState] = useState({ + isInitiatingAuth: false, + oauthTokens: null, + loading: true, + oauthStep: "not_started", + oauthMetadata: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, + }); + + // Helper function to update specific state properties + const updateState = (updates: Partial) => { + setState(prev => ({ ...prev, ...updates })); + }; // Load OAuth tokens on component mount useEffect(() => { @@ -81,14 +96,16 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { const parsedTokens = await OAuthTokensSchema.parseAsync( JSON.parse(tokens), ); - setOAuthTokens(parsedTokens); - setOAuthStep("complete"); + updateState({ + oauthTokens: parsedTokens, + oauthStep: "complete", + }); } } } catch (error) { console.error("Error loading OAuth tokens:", error); } finally { - setLoading(false); + updateState({ loading: false }); } }; @@ -100,15 +117,17 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { const debugCode = sessionStorage.getItem(SESSION_KEYS.DEBUG_CODE); if (debugCode && sseUrl) { // We've returned from a debug OAuth callback with a code - setAuthorizationCode(debugCode); - setOAuthStep("token_request"); + updateState({ + authorizationCode: debugCode, + oauthStep: "token_request", + }); // Load client info asynchronously const provider = new DebugInspectorOAuthClientProvider(sseUrl); provider .clientInformation() .then((info) => { - setOAuthClientInfo(info || null); + updateState({ oauthClientInfo: info || null }); }) .catch((error) => { console.error("Failed to load client information:", error); @@ -141,18 +160,22 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { const startOAuthFlow = () => { if (!sseUrl) { - setStatusMessage({ - type: "error", - message: - "Please enter a server URL in the sidebar before authenticating", + updateState({ + statusMessage: { + type: "error", + message: + "Please enter a server URL in the sidebar before authenticating", + }, }); return; } - setOAuthStep("not_started"); - setAuthorizationUrl(null); - setStatusMessage(null); - setLatestError(null); + updateState({ + oauthStep: "not_started", + authorizationUrl: null, + statusMessage: null, + latestError: null, + }); }; const proceedToNextStep = async () => { @@ -160,22 +183,24 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { const provider = new DebugInspectorOAuthClientProvider(sseUrl); try { - setIsInitiatingAuth(true); - setStatusMessage(null); - setLatestError(null); + updateState({ + isInitiatingAuth: true, + statusMessage: null, + latestError: null, + }); - if (oauthStep === "not_started") { - setOAuthStep("metadata_discovery"); + if (state.oauthStep === "not_started") { + updateState({ oauthStep: "metadata_discovery" }); const metadata = await discoverOAuthMetadata(sseUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); } const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); - setOAuthMetadata(parsedMetadata); - } else if (oauthStep === "metadata_discovery") { - const metadata = validateOAuthMetadata(oauthMetadata); + updateState({ oauthMetadata: parsedMetadata }); + } else if (state.oauthStep === "metadata_discovery") { + const metadata = validateOAuthMetadata(state.oauthMetadata); - setOAuthStep("client_registration"); + updateState({ oauthStep: "client_registration" }); const clientMetadata = provider.clientMetadata; // Add all supported scopes to client registration. @@ -189,11 +214,11 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { }); provider.saveClientInformation(fullInformation); - setOAuthClientInfo(fullInformation); - } else if (oauthStep === "client_registration") { - const metadata = validateOAuthMetadata(oauthMetadata); + updateState({ oauthClientInfo: fullInformation }); + } else if (state.oauthStep === "client_registration") { + const metadata = validateOAuthMetadata(state.oauthMetadata); const clientInformation = await validateClientInformation(provider); - setOAuthStep("authorization_redirect"); + updateState({ oauthStep: "authorization_redirect" }); try { const { authorizationUrl, codeVerifier } = await startAuthorization( sseUrl, @@ -209,77 +234,80 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { if (metadata.scopes_supported) { const url = new URL(authorizationUrl.toString()); url.searchParams.set("scope", metadata.scopes_supported.join(" ")); - setAuthorizationUrl(url.toString()); + updateState({ authorizationUrl: url.toString() }); } else { - setAuthorizationUrl(authorizationUrl.toString()); + updateState({ authorizationUrl: authorizationUrl.toString() }); } - setOAuthStep("authorization_code"); + updateState({ oauthStep: "authorization_code" }); } catch (error) { console.error("OAuth flow step error:", error); throw new Error( `Failed to complete OAuth setup: ${error instanceof Error ? error.message : String(error)}`, ); } - } else if (oauthStep === "authorization_code") { - if (!authorizationCode || authorizationCode.trim() === "") { - setValidationError("You need to provide an authorization code"); + } else if (state.oauthStep === "authorization_code") { + if (!state.authorizationCode || state.authorizationCode.trim() === "") { + updateState({ validationError: "You need to provide an authorization code" }); return; } - setValidationError(null); - setOAuthStep("token_request"); - } else if (oauthStep === "token_request") { + updateState({ validationError: null, oauthStep: "token_request" }); + } else if (state.oauthStep === "token_request") { const codeVerifier = provider.codeVerifier(); - const metadata = validateOAuthMetadata(oauthMetadata); + const metadata = validateOAuthMetadata(state.oauthMetadata); const clientInformation = await validateClientInformation(provider); const tokens = await exchangeAuthorization(sseUrl, { metadata, clientInformation, - authorizationCode, + authorizationCode: state.authorizationCode, codeVerifier, redirectUri: provider.redirectUrl, }); provider.saveTokens(tokens); - setOAuthTokens(tokens); - setOAuthStep("complete"); + updateState({ oauthTokens: tokens, oauthStep: "complete" }); } } catch (error) { console.error("OAuth flow error:", error); - setLatestError(error instanceof Error ? error : new Error(String(error))); + updateState({ latestError: error instanceof Error ? error : new Error(String(error)) }); } finally { - setIsInitiatingAuth(false); + updateState({ isInitiatingAuth: false }); } }; const handleStartOAuth = async () => { if (!sseUrl) { - setStatusMessage({ - type: "error", - message: - "Please enter a server URL in the sidebar before authenticating", + updateState({ + statusMessage: { + type: "error", + message: + "Please enter a server URL in the sidebar before authenticating", + }, }); return; } - setIsInitiatingAuth(true); - setStatusMessage(null); + updateState({ isInitiatingAuth: true, statusMessage: null }); try { const serverAuthProvider = new DebugInspectorOAuthClientProvider(sseUrl); await auth(serverAuthProvider, { serverUrl: sseUrl }); - setStatusMessage({ - type: "info", - message: "Starting OAuth authentication process...", + updateState({ + statusMessage: { + type: "info", + message: "Starting OAuth authentication process...", + }, }); } catch (error) { console.error("OAuth initialization error:", error); - setStatusMessage({ - type: "error", - message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, + updateState({ + statusMessage: { + type: "error", + message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, + }, }); } finally { - setIsInitiatingAuth(false); + updateState({ isInitiatingAuth: false }); } }; @@ -287,43 +315,45 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { if (sseUrl) { const serverAuthProvider = new DebugInspectorOAuthClientProvider(sseUrl); serverAuthProvider.clear(); - setOAuthTokens(null); - setOAuthStep("not_started"); - setLatestError(null); - setOAuthClientInfo(null); - setAuthorizationCode(""); - setValidationError(null); - setStatusMessage({ - type: "success", - message: "OAuth tokens cleared successfully", + updateState({ + oauthTokens: null, + oauthStep: "not_started", + latestError: null, + oauthClientInfo: null, + authorizationCode: "", + validationError: null, + statusMessage: { + type: "success", + message: "OAuth tokens cleared successfully", + }, }); // Clear success message after 3 seconds setTimeout(() => { - setStatusMessage(null); + updateState({ statusMessage: null }); }, 3000); } }; const renderStatusMessage = () => { - if (!statusMessage) return null; + if (!state.statusMessage) return null; const bgColor = - statusMessage.type === "error" + state.statusMessage.type === "error" ? "bg-red-50" - : statusMessage.type === "success" + : state.statusMessage.type === "success" ? "bg-green-50" : "bg-blue-50"; const textColor = - statusMessage.type === "error" + state.statusMessage.type === "error" ? "text-red-700" - : statusMessage.type === "success" + : state.statusMessage.type === "success" ? "text-green-700" : "text-blue-700"; const borderColor = - statusMessage.type === "error" + state.statusMessage.type === "error" ? "border-red-200" - : statusMessage.type === "success" + : state.statusMessage.type === "success" ? "border-green-200" : "border-blue-200"; @@ -333,7 +363,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { >
-

{statusMessage.message}

+

{state.statusMessage.message}

); @@ -349,14 +379,14 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "metadata_discovery", label: "Metadata Discovery", - metadata: oauthMetadata && ( + metadata: state.oauthMetadata && (
Retrieved OAuth Metadata from {sseUrl} /.well-known/oauth-authorization-server
-              {JSON.stringify(oauthMetadata, null, 2)}
+              {JSON.stringify(state.oauthMetadata, null, 2)}
             
), @@ -364,13 +394,13 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "client_registration", label: "Client Registration", - metadata: oauthClientInfo && ( + metadata: state.oauthClientInfo && (
Registered Client Information
-              {JSON.stringify(oauthClientInfo, null, 2)}
+              {JSON.stringify(state.oauthClientInfo, null, 2)}
             
), @@ -378,13 +408,13 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "authorization_redirect", label: "Preparing Authorization", - metadata: authorizationUrl && ( + metadata: state.authorizationUrl && (

Authorization URL:

-

{authorizationUrl}

+

{state.authorizationUrl}

{
{ - setAuthorizationCode(e.target.value); - setValidationError(null); + updateState({ + 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 ${ - validationError ? "border-red-500" : "border-input" + state.validationError ? "border-red-500" : "border-input" }`} />
- {validationError && ( -

{validationError}

+ {state.validationError && ( +

{state.validationError}

)}

Once you've completed authorization in the link, paste the code @@ -439,7 +471,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "token_request", label: "Token Request", - metadata: oauthMetadata && ( + metadata: state.oauthMetadata && (

Token Request Details @@ -447,7 +479,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {

Token Endpoint:

- {oauthMetadata.token_endpoint} + {state.oauthMetadata.token_endpoint}
@@ -456,7 +488,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "complete", label: "Authentication Complete", - metadata: oauthTokens && ( + metadata: state.oauthTokens && (
Access Tokens @@ -467,7 +499,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { requests.

-              {JSON.stringify(oauthTokens, null, 2)}
+              {JSON.stringify(state.oauthTokens, null, 2)}
             
), @@ -483,9 +515,9 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {
{steps.map((step, idx) => { - const currentStepIdx = steps.findIndex((s) => s.key === oauthStep); + const currentStepIdx = steps.findIndex((s) => s.key === state.oauthStep); const isComplete = idx <= currentStepIdx; - const isCurrent = step.key === oauthStep; + const isCurrent = step.key === state.oauthStep; return (
@@ -508,11 +540,11 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { )} {/* Display error if current step and an error exists */} - {isCurrent && latestError && ( + {isCurrent && state.latestError && (

Error:

- {latestError.message} + {state.latestError.message}

)} @@ -522,20 +554,20 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {
- {oauthStep !== "complete" && ( - )} - {oauthStep === "authorization_redirect" && authorizationUrl && ( + {state.oauthStep === "authorization_redirect" && state.authorizationUrl && ( @@ -569,15 +601,15 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { {renderStatusMessage()} - {loading ? ( + {state.loading ? (

Loading authentication status...

) : (
- {oauthTokens && ( + {state.oauthTokens && (

Access Token:

- {oauthTokens.access_token.substring(0, 25)}... + {state.oauthTokens.access_token.substring(0, 25)}...
)} @@ -586,20 +618,20 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { @@ -625,4 +657,4 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { ); }; -export default AuthDebugger; +export default AuthDebugger; \ No newline at end of file From 2f8ab16a23ed3f039eaf453884a42d3a1d73a00b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 12:40:20 +0100 Subject: [PATCH 08/35] prettier --- client/src/components/AuthDebugger.tsx | 45 ++++++++----- client/src/components/OAuthDebugCallback.tsx | 7 ++- .../__tests__/AuthDebugger.test.tsx | 63 +++++++++++-------- 3 files changed, 70 insertions(+), 45 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 95ab8a11..316a043f 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -82,7 +82,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { // Helper function to update specific state properties const updateState = (updates: Partial) => { - setState(prev => ({ ...prev, ...updates })); + setState((prev) => ({ ...prev, ...updates })); }; // Load OAuth tokens on component mount @@ -248,7 +248,9 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { } } else if (state.oauthStep === "authorization_code") { if (!state.authorizationCode || state.authorizationCode.trim() === "") { - updateState({ validationError: "You need to provide an authorization code" }); + updateState({ + validationError: "You need to provide an authorization code", + }); return; } updateState({ validationError: null, oauthStep: "token_request" }); @@ -270,7 +272,9 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { } } catch (error) { console.error("OAuth flow error:", error); - updateState({ latestError: error instanceof Error ? error : new Error(String(error)) }); + updateState({ + latestError: error instanceof Error ? error : new Error(String(error)), + }); } finally { updateState({ isInitiatingAuth: false }); } @@ -459,7 +463,9 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { />
{state.validationError && ( -

{state.validationError}

+

+ {state.validationError} +

)}

Once you've completed authorization in the link, paste the code @@ -515,7 +521,9 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {

{steps.map((step, idx) => { - const currentStepIdx = steps.findIndex((s) => s.key === state.oauthStep); + const currentStepIdx = steps.findIndex( + (s) => s.key === state.oauthStep, + ); const isComplete = idx <= currentStepIdx; const isCurrent = step.key === state.oauthStep; @@ -555,23 +563,28 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {
{state.oauthStep !== "complete" && ( - )} - {state.oauthStep === "authorization_redirect" && state.authorizationUrl && ( - - )} + {state.oauthStep === "authorization_redirect" && + state.authorizationUrl && ( + + )}
); @@ -657,4 +670,4 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { ); }; -export default AuthDebugger; \ No newline at end of file +export default AuthDebugger; diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 6ebf91b6..00be15ec 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -55,10 +55,11 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { // Finally, trigger navigation back to auth debugger toast({ title: "Success", - description: "Authorization code received. Please return to the Auth Debugger.", + description: + "Authorization code received. Please return to the Auth Debugger.", variant: "default", }); - + // Call onConnect to navigate back to the auth debugger onConnect(serverUrl); }; @@ -85,7 +86,7 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { Please copy this authorization code and return to the Auth Debugger:

- {callbackParams.successful && 'code' in callbackParams + {callbackParams.successful && "code" in callbackParams ? callbackParams.code : `No code found: ${callbackParams.error}, ${callbackParams.error_description}`} diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 76196cae..f2e370d6 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -1,4 +1,10 @@ -import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, beforeEach, jest } from "@jest/globals"; import AuthDebugger from "../AuthDebugger"; @@ -10,7 +16,7 @@ const mockOAuthTokens = { token_type: "Bearer", expires_in: 3600, refresh_token: "test_refresh_token", - scope: "test_scope" + scope: "test_scope", }; const mockOAuthMetadata = { @@ -59,14 +65,14 @@ const sessionStorageMock = { removeItem: jest.fn(), clear: jest.fn(), }; -Object.defineProperty(window, 'sessionStorage', { +Object.defineProperty(window, "sessionStorage", { value: sessionStorageMock, }); // Mock the location.origin -Object.defineProperty(window, 'location', { +Object.defineProperty(window, "location", { value: { - origin: 'http://localhost:3000', + origin: "http://localhost:3000", }, }); @@ -79,11 +85,13 @@ describe("AuthDebugger", () => { beforeEach(() => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - (mockDiscoverOAuthMetadata as jest.Mock).mockResolvedValue(mockOAuthMetadata); + (mockDiscoverOAuthMetadata as jest.Mock).mockResolvedValue( + mockOAuthMetadata, + ); (mockRegisterClient as jest.Mock).mockResolvedValue(mockOAuthClientInfo); (mockStartAuthorization as jest.Mock).mockResolvedValue({ authorizationUrl: new URL("https://oauth.example.com/authorize"), - codeVerifier: "test_verifier" + codeVerifier: "test_verifier", }); (mockExchangeAuthorization as jest.Mock).mockResolvedValue(mockOAuthTokens); }); @@ -92,7 +100,7 @@ describe("AuthDebugger", () => { return render( - + , ); }; @@ -119,11 +127,11 @@ describe("AuthDebugger", () => { await act(async () => { renderAuthDebugger(); }); - + await act(async () => { fireEvent.click(screen.getByText("Guided OAuth Flow")); }); - + expect(screen.getByText("OAuth Flow Progress")).toBeInTheDocument(); }); @@ -131,14 +139,15 @@ describe("AuthDebugger", () => { await act(async () => { renderAuthDebugger({ sseUrl: "" }); }); - + await act(async () => { fireEvent.click(screen.getByText("Guided OAuth Flow")); }); - + expect(mockToast).toHaveBeenCalledWith({ title: "Error", - description: "Please enter a server URL in the sidebar before authenticating", + description: + "Please enter a server URL in the sidebar before authenticating", variant: "destructive", }); }); @@ -153,11 +162,11 @@ describe("AuthDebugger", () => { } return null; }); - + await act(async () => { renderAuthDebugger(); }); - + await waitFor(() => { expect(screen.getByText(/Access Token:/)).toBeInTheDocument(); }); @@ -167,7 +176,7 @@ describe("AuthDebugger", () => { // Mock console to avoid cluttering test output const originalError = console.error; console.error = jest.fn(); - + // Mock getItem to return invalid JSON for tokens sessionStorageMock.getItem.mockImplementation((key) => { if (key === "[https://example.com] mcp_tokens") { @@ -175,14 +184,14 @@ describe("AuthDebugger", () => { } return null; }); - + await act(async () => { renderAuthDebugger(); }); - + // Component should still render despite the error expect(screen.getByText("Authentication Settings")).toBeInTheDocument(); - + // Restore console.error console.error = originalError; }); @@ -197,7 +206,7 @@ describe("AuthDebugger", () => { } return null; }); - + await act(async () => { renderAuthDebugger(); }); @@ -221,21 +230,23 @@ describe("AuthDebugger", () => { await act(async () => { renderAuthDebugger(); }); - + // Start guided flow await act(async () => { fireEvent.click(screen.getByText("Guided OAuth Flow")); }); - + // 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(mockDiscoverOAuthMetadata).toHaveBeenCalledWith("https://example.com"); + + expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( + "https://example.com", + ); }); }); -}); \ No newline at end of file +}); From 5158c22da45a91b5af2957128cbda1a00369613f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 12:46:50 +0100 Subject: [PATCH 09/35] hoist state up to App --- client/src/App.tsx | 66 ++++++ client/src/components/AuthDebugger.tsx | 289 +++++++++---------------- client/src/lib/auth-types.ts | 46 ++++ 3 files changed, 212 insertions(+), 189 deletions(-) create mode 100644 client/src/lib/auth-types.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index cf073aa8..88637ad5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,6 +17,9 @@ import { Tool, LoggingLevel, } from "@modelcontextprotocol/sdk/types.js"; +import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { SESSION_KEYS, getServerSpecificKey } from "./lib/constants"; +import { AuthDebuggerState } from "./lib/auth-types"; import React, { Suspense, useCallback, @@ -139,6 +142,26 @@ const App = () => { > >([]); const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); + + // Auth debugger state (moved from AuthDebugger component) + const [authState, setAuthState] = useState({ + isInitiatingAuth: false, + oauthTokens: null, + loading: true, + oauthStep: "not_started", + oauthMetadata: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, + }); + + // Helper function to update specific auth state properties + const updateAuthState = (updates: Partial) => { + setAuthState((prev) => ({ ...prev, ...updates })); + }; const nextRequestId = useRef(0); const rootsRef = useRef([]); @@ -246,6 +269,47 @@ const App = () => { setIsAuthDebuggerVisible(true); }, []); + // Load OAuth tokens when sseUrl changes (moved from AuthDebugger) + useEffect(() => { + const loadOAuthTokens = async () => { + try { + if (sseUrl) { + const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl); + const tokens = sessionStorage.getItem(key); + if (tokens) { + const parsedTokens = await OAuthTokensSchema.parseAsync( + JSON.parse(tokens), + ); + updateAuthState({ + oauthTokens: parsedTokens, + oauthStep: "complete", + }); + } + } + } catch (error) { + console.error("Error loading OAuth tokens:", error); + } finally { + updateAuthState({ loading: false }); + } + }; + + loadOAuthTokens(); + }, [sseUrl]); + + // Check for debug callback code (moved from AuthDebugger) + useEffect(() => { + const debugCode = sessionStorage.getItem(SESSION_KEYS.DEBUG_CODE); + if (debugCode && sseUrl) { + updateAuthState({ + authorizationCode: debugCode, + oauthStep: "token_request", + }); + + // Now that we've processed it, clear the debug code + sessionStorage.removeItem(SESSION_KEYS.DEBUG_CODE); + } + }, [sseUrl]); + useEffect(() => { fetch(`${getMCPProxyAddress(config)}/config`) .then((response) => response.json()) @@ -485,6 +549,8 @@ const App = () => { setIsAuthDebuggerVisible(false)} + authState={authState} + updateAuthState={updateAuthState} /> ); diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 316a043f..1d0ac227 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { InspectorOAuthClientProvider } from "../lib/auth"; import { @@ -8,53 +8,20 @@ import { startAuthorization, exchangeAuthorization, } from "@modelcontextprotocol/sdk/client/auth.js"; -import { SESSION_KEYS, getServerSpecificKey } from "../lib/constants"; +import { SESSION_KEYS } from "../lib/constants"; import { - OAuthTokensSchema, OAuthMetadataSchema, OAuthMetadata, - OAuthClientInformationFull, OAuthClientInformation, - OAuthTokens, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { CheckCircle2, Circle, ExternalLink, AlertCircle } from "lucide-react"; +import { AuthDebuggerState } from "../lib/auth-types"; interface AuthDebuggerProps { sseUrl: string; onBack: () => void; -} - -// OAuth flow steps -type OAuthStep = - | "not_started" - | "metadata_discovery" - | "client_registration" - | "authorization_redirect" - | "authorization_code" - | "token_request" - | "complete"; - -// Message types for inline feedback -type MessageType = "success" | "error" | "info"; - -interface StatusMessage { - type: MessageType; - message: string; -} - -// Single state interface to replace multiple useState calls -interface AuthDebuggerState { - isInitiatingAuth: boolean; - oauthTokens: OAuthTokens | null; - loading: boolean; - oauthStep: OAuthStep; - oauthMetadata: OAuthMetadata | null; - oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; - authorizationUrl: string | null; - authorizationCode: string; - latestError: Error | null; - statusMessage: StatusMessage | null; - validationError: string | null; + authState: AuthDebuggerState; + updateAuthState: (updates: Partial) => void; } // Enhanced version of the OAuth client provider specifically for debug flows @@ -64,79 +31,23 @@ class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { } } -const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { - // Single state object instead of multiple useState calls - const [state, setState] = useState({ - isInitiatingAuth: false, - oauthTokens: null, - loading: true, - oauthStep: "not_started", - oauthMetadata: null, - oauthClientInfo: null, - authorizationUrl: null, - authorizationCode: "", - latestError: null, - statusMessage: null, - validationError: null, - }); - - // Helper function to update specific state properties - const updateState = (updates: Partial) => { - setState((prev) => ({ ...prev, ...updates })); - }; - - // Load OAuth tokens on component mount - useEffect(() => { - const loadOAuthTokens = async () => { - try { - if (sseUrl) { - const key = getServerSpecificKey(SESSION_KEYS.TOKENS, sseUrl); - const tokens = sessionStorage.getItem(key); - if (tokens) { - const parsedTokens = await OAuthTokensSchema.parseAsync( - JSON.parse(tokens), - ); - updateState({ - oauthTokens: parsedTokens, - oauthStep: "complete", - }); - } - } - } catch (error) { - console.error("Error loading OAuth tokens:", error); - } finally { - updateState({ loading: false }); - } - }; - - loadOAuthTokens(); - }, [sseUrl]); - - // Check for debug callback code and load client info +const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebuggerProps) => { + // Load client info asynchronously when we have a debug code useEffect(() => { const debugCode = sessionStorage.getItem(SESSION_KEYS.DEBUG_CODE); - if (debugCode && sseUrl) { - // We've returned from a debug OAuth callback with a code - updateState({ - authorizationCode: debugCode, - oauthStep: "token_request", - }); - + if (debugCode && sseUrl && authState.oauthStep === "token_request") { // Load client info asynchronously const provider = new DebugInspectorOAuthClientProvider(sseUrl); provider .clientInformation() .then((info) => { - updateState({ oauthClientInfo: info || null }); + updateAuthState({ oauthClientInfo: info || null }); }) .catch((error) => { console.error("Failed to load client information:", error); }); - - // Now that we've processed it, clear the debug code - sessionStorage.removeItem(SESSION_KEYS.DEBUG_CODE); } - }, [sseUrl]); + }, [sseUrl, authState.oauthStep, updateAuthState]); const validateOAuthMetadata = ( metadata: OAuthMetadata | null, @@ -158,9 +69,9 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { return clientInformation; }; - const startOAuthFlow = () => { + const startOAuthFlow = useCallback(() => { if (!sseUrl) { - updateState({ + updateAuthState({ statusMessage: { type: "error", message: @@ -170,37 +81,37 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { return; } - updateState({ + updateAuthState({ oauthStep: "not_started", authorizationUrl: null, statusMessage: null, latestError: null, }); - }; + }, [sseUrl, updateAuthState]); - const proceedToNextStep = async () => { + const proceedToNextStep = useCallback(async () => { if (!sseUrl) return; const provider = new DebugInspectorOAuthClientProvider(sseUrl); try { - updateState({ + updateAuthState({ isInitiatingAuth: true, statusMessage: null, latestError: null, }); - if (state.oauthStep === "not_started") { - updateState({ oauthStep: "metadata_discovery" }); + if (authState.oauthStep === "not_started") { + updateAuthState({ oauthStep: "metadata_discovery" }); const metadata = await discoverOAuthMetadata(sseUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); } const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); - updateState({ oauthMetadata: parsedMetadata }); - } else if (state.oauthStep === "metadata_discovery") { - const metadata = validateOAuthMetadata(state.oauthMetadata); + updateAuthState({ oauthMetadata: parsedMetadata }); + } else if (authState.oauthStep === "metadata_discovery") { + const metadata = validateOAuthMetadata(authState.oauthMetadata); - updateState({ oauthStep: "client_registration" }); + updateAuthState({ oauthStep: "client_registration" }); const clientMetadata = provider.clientMetadata; // Add all supported scopes to client registration. @@ -214,11 +125,11 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { }); provider.saveClientInformation(fullInformation); - updateState({ oauthClientInfo: fullInformation }); - } else if (state.oauthStep === "client_registration") { - const metadata = validateOAuthMetadata(state.oauthMetadata); + updateAuthState({ oauthClientInfo: fullInformation }); + } else if (authState.oauthStep === "client_registration") { + const metadata = validateOAuthMetadata(authState.oauthMetadata); const clientInformation = await validateClientInformation(provider); - updateState({ oauthStep: "authorization_redirect" }); + updateAuthState({ oauthStep: "authorization_redirect" }); try { const { authorizationUrl, codeVerifier } = await startAuthorization( sseUrl, @@ -234,55 +145,55 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { if (metadata.scopes_supported) { const url = new URL(authorizationUrl.toString()); url.searchParams.set("scope", metadata.scopes_supported.join(" ")); - updateState({ authorizationUrl: url.toString() }); + updateAuthState({ authorizationUrl: url.toString() }); } else { - updateState({ authorizationUrl: authorizationUrl.toString() }); + updateAuthState({ authorizationUrl: authorizationUrl.toString() }); } - updateState({ oauthStep: "authorization_code" }); + updateAuthState({ oauthStep: "authorization_code" }); } catch (error) { console.error("OAuth flow step error:", error); throw new Error( `Failed to complete OAuth setup: ${error instanceof Error ? error.message : String(error)}`, ); } - } else if (state.oauthStep === "authorization_code") { - if (!state.authorizationCode || state.authorizationCode.trim() === "") { - updateState({ + } else if (authState.oauthStep === "authorization_code") { + if (!authState.authorizationCode || authState.authorizationCode.trim() === "") { + updateAuthState({ validationError: "You need to provide an authorization code", }); return; } - updateState({ validationError: null, oauthStep: "token_request" }); - } else if (state.oauthStep === "token_request") { + updateAuthState({ validationError: null, oauthStep: "token_request" }); + } else if (authState.oauthStep === "token_request") { const codeVerifier = provider.codeVerifier(); - const metadata = validateOAuthMetadata(state.oauthMetadata); + const metadata = validateOAuthMetadata(authState.oauthMetadata); const clientInformation = await validateClientInformation(provider); const tokens = await exchangeAuthorization(sseUrl, { metadata, clientInformation, - authorizationCode: state.authorizationCode, + authorizationCode: authState.authorizationCode, codeVerifier, redirectUri: provider.redirectUrl, }); provider.saveTokens(tokens); - updateState({ oauthTokens: tokens, oauthStep: "complete" }); + updateAuthState({ oauthTokens: tokens, oauthStep: "complete" }); } } catch (error) { console.error("OAuth flow error:", error); - updateState({ + updateAuthState({ latestError: error instanceof Error ? error : new Error(String(error)), }); } finally { - updateState({ isInitiatingAuth: false }); + updateAuthState({ isInitiatingAuth: false }); } - }; + }, [sseUrl, authState, updateAuthState]); - const handleStartOAuth = async () => { + const handleStartOAuth = useCallback(async () => { if (!sseUrl) { - updateState({ + updateAuthState({ statusMessage: { type: "error", message: @@ -292,11 +203,11 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { return; } - updateState({ isInitiatingAuth: true, statusMessage: null }); + updateAuthState({ isInitiatingAuth: true, statusMessage: null }); try { const serverAuthProvider = new DebugInspectorOAuthClientProvider(sseUrl); await auth(serverAuthProvider, { serverUrl: sseUrl }); - updateState({ + updateAuthState({ statusMessage: { type: "info", message: "Starting OAuth authentication process...", @@ -304,22 +215,22 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { }); } catch (error) { console.error("OAuth initialization error:", error); - updateState({ + updateAuthState({ statusMessage: { type: "error", message: `Failed to start OAuth flow: ${error instanceof Error ? error.message : String(error)}`, }, }); } finally { - updateState({ isInitiatingAuth: false }); + updateAuthState({ isInitiatingAuth: false }); } - }; + }, [sseUrl, updateAuthState]); - const handleClearOAuth = () => { + const handleClearOAuth = useCallback(() => { if (sseUrl) { const serverAuthProvider = new DebugInspectorOAuthClientProvider(sseUrl); serverAuthProvider.clear(); - updateState({ + updateAuthState({ oauthTokens: null, oauthStep: "not_started", latestError: null, @@ -334,30 +245,30 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { // Clear success message after 3 seconds setTimeout(() => { - updateState({ statusMessage: null }); + updateAuthState({ statusMessage: null }); }, 3000); } - }; + }, [sseUrl, updateAuthState]); - const renderStatusMessage = () => { - if (!state.statusMessage) return null; + const renderStatusMessage = useCallback(() => { + if (!authState.statusMessage) return null; const bgColor = - state.statusMessage.type === "error" + authState.statusMessage.type === "error" ? "bg-red-50" - : state.statusMessage.type === "success" + : authState.statusMessage.type === "success" ? "bg-green-50" : "bg-blue-50"; const textColor = - state.statusMessage.type === "error" + authState.statusMessage.type === "error" ? "text-red-700" - : state.statusMessage.type === "success" + : authState.statusMessage.type === "success" ? "text-green-700" : "text-blue-700"; const borderColor = - state.statusMessage.type === "error" + authState.statusMessage.type === "error" ? "border-red-200" - : state.statusMessage.type === "success" + : authState.statusMessage.type === "success" ? "border-green-200" : "border-blue-200"; @@ -367,13 +278,13 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { >
-

{state.statusMessage.message}

+

{authState.statusMessage.message}

); - }; + }, [authState.statusMessage]); - const renderOAuthFlow = () => { + const renderOAuthFlow = useCallback(() => { const steps = [ { key: "not_started", @@ -383,14 +294,14 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "metadata_discovery", label: "Metadata Discovery", - metadata: state.oauthMetadata && ( + metadata: authState.oauthMetadata && (
Retrieved OAuth Metadata from {sseUrl} /.well-known/oauth-authorization-server
-              {JSON.stringify(state.oauthMetadata, null, 2)}
+              {JSON.stringify(authState.oauthMetadata, null, 2)}
             
), @@ -398,13 +309,13 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "client_registration", label: "Client Registration", - metadata: state.oauthClientInfo && ( + metadata: authState.oauthClientInfo && (
Registered Client Information
-              {JSON.stringify(state.oauthClientInfo, null, 2)}
+              {JSON.stringify(authState.oauthClientInfo, null, 2)}
             
), @@ -412,13 +323,13 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "authorization_redirect", label: "Preparing Authorization", - metadata: state.authorizationUrl && ( + metadata: authState.authorizationUrl && (

Authorization URL:

-

{state.authorizationUrl}

+

{authState.authorizationUrl}

{
{ - updateState({ + 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 ${ - state.validationError ? "border-red-500" : "border-input" + authState.validationError ? "border-red-500" : "border-input" }`} />
- {state.validationError && ( + {authState.validationError && (

- {state.validationError} + {authState.validationError}

)}

@@ -477,7 +388,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "token_request", label: "Token Request", - metadata: state.oauthMetadata && ( + metadata: authState.oauthMetadata && (

Token Request Details @@ -485,7 +396,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {

Token Endpoint:

- {state.oauthMetadata.token_endpoint} + {authState.oauthMetadata.token_endpoint}
@@ -494,7 +405,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { { key: "complete", label: "Authentication Complete", - metadata: state.oauthTokens && ( + metadata: authState.oauthTokens && (
Access Tokens @@ -505,7 +416,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { requests.

-              {JSON.stringify(state.oauthTokens, null, 2)}
+              {JSON.stringify(authState.oauthTokens, null, 2)}
             
), @@ -522,10 +433,10 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {
{steps.map((step, idx) => { const currentStepIdx = steps.findIndex( - (s) => s.key === state.oauthStep, + (s) => s.key === authState.oauthStep, ); const isComplete = idx <= currentStepIdx; - const isCurrent = step.key === state.oauthStep; + const isCurrent = step.key === authState.oauthStep; return (
@@ -548,11 +459,11 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { )} {/* Display error if current step and an error exists */} - {isCurrent && state.latestError && ( + {isCurrent && authState.latestError && (

Error:

- {state.latestError.message} + {authState.latestError.message}

)} @@ -562,25 +473,25 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {
- {state.oauthStep !== "complete" && ( + {authState.oauthStep !== "complete" && ( )} - {state.oauthStep === "authorization_redirect" && - state.authorizationUrl && ( + {authState.oauthStep === "authorization_redirect" && + authState.authorizationUrl && ( @@ -588,7 +499,7 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => {
); - }; + }, [authState, sseUrl]); return (
@@ -614,15 +525,15 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { {renderStatusMessage()} - {state.loading ? ( + {authState.loading ? (

Loading authentication status...

) : (
- {state.oauthTokens && ( + {authState.oauthTokens && (

Access Token:

- {state.oauthTokens.access_token.substring(0, 25)}... + {authState.oauthTokens.access_token.substring(0, 25)}...
)} @@ -631,20 +542,20 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { @@ -670,4 +581,4 @@ const AuthDebugger = ({ sseUrl, onBack }: AuthDebuggerProps) => { ); }; -export default AuthDebugger; +export default AuthDebugger; \ No newline at end of file diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts new file mode 100644 index 00000000..77069124 --- /dev/null +++ b/client/src/lib/auth-types.ts @@ -0,0 +1,46 @@ +import { + OAuthMetadata, + OAuthClientInformationFull, + OAuthClientInformation, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; + +// OAuth flow steps +export type OAuthStep = + | "not_started" + | "metadata_discovery" + | "client_registration" + | "authorization_redirect" + | "authorization_code" + | "token_request" + | "complete"; + +// Message types for inline feedback +export type MessageType = "success" | "error" | "info"; + +export interface StatusMessage { + type: MessageType; + message: string; +} + +// Single state interface for OAuth state +export interface AuthDebuggerState { + isInitiatingAuth: boolean; + oauthTokens: OAuthTokens | null; + loading: boolean; + oauthStep: OAuthStep; + oauthMetadata: OAuthMetadata | null; + oauthClientInfo: OAuthClientInformationFull | OAuthClientInformation | null; + authorizationUrl: string | null; + authorizationCode: string; + latestError: Error | null; + statusMessage: StatusMessage | null; + validationError: string | null; +} + +// Enhanced version of the OAuth client provider specifically for debug flows +export class DebugInspectorOAuthClientProvider { + get redirectUrl(): string { + return `${window.location.origin}/oauth/callback/debug`; + } +} \ No newline at end of file From 5dca3ed6d9c9bb89ebd6e62f29641ddf25aa9838 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 13:18:55 +0100 Subject: [PATCH 10/35] working with quick and guided --- client/src/App.tsx | 28 +++--- client/src/components/AuthDebugger.tsx | 93 +++++++++++++------- client/src/components/OAuthDebugCallback.tsx | 15 ++-- client/src/lib/auth.ts | 2 +- client/src/lib/constants.ts | 6 +- 5 files changed, 79 insertions(+), 65 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 88637ad5..f62d2534 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -142,7 +142,7 @@ const App = () => { > >([]); const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); - + // Auth debugger state (moved from AuthDebugger component) const [authState, setAuthState] = useState({ isInitiatingAuth: false, @@ -265,11 +265,17 @@ const App = () => { ); // Auto-connect to previously saved serverURL after OAuth callback - const onOAuthDebugConnect = useCallback(() => { + const onOAuthDebugConnect = useCallback((serverUrl: string, authorizationCode?: string) => { setIsAuthDebuggerVisible(true); + if (authorizationCode) { + updateAuthState({ + authorizationCode, + oauthStep: "token_request", + }); + } }, []); - // Load OAuth tokens when sseUrl changes (moved from AuthDebugger) + // Load OAuth tokens when sseUrl changes useEffect(() => { const loadOAuthTokens = async () => { try { @@ -296,20 +302,6 @@ const App = () => { loadOAuthTokens(); }, [sseUrl]); - // Check for debug callback code (moved from AuthDebugger) - useEffect(() => { - const debugCode = sessionStorage.getItem(SESSION_KEYS.DEBUG_CODE); - if (debugCode && sseUrl) { - updateAuthState({ - authorizationCode: debugCode, - oauthStep: "token_request", - }); - - // Now that we've processed it, clear the debug code - sessionStorage.removeItem(SESSION_KEYS.DEBUG_CODE); - } - }, [sseUrl]); - useEffect(() => { fetch(`${getMCPProxyAddress(config)}/config`) .then((response) => response.json()) @@ -558,7 +550,7 @@ const App = () => { // Helper function to render OAuth callback components const renderOAuthCallback = ( path: string, - onConnect: (serverUrl: string) => void, + onConnect: (serverUrl: string, authorizationCode?: string) => void, ) => { const Component = path === "/oauth/callback" diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 1d0ac227..9ba9e703 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -8,7 +8,6 @@ import { startAuthorization, exchangeAuthorization, } from "@modelcontextprotocol/sdk/client/auth.js"; -import { SESSION_KEYS } from "../lib/constants"; import { OAuthMetadataSchema, OAuthMetadata, @@ -16,6 +15,7 @@ import { } from "@modelcontextprotocol/sdk/shared/auth.js"; import { CheckCircle2, Circle, ExternalLink, AlertCircle } from "lucide-react"; import { AuthDebuggerState } from "../lib/auth-types"; +import { SESSION_KEYS, getServerSpecificKey } from "../lib/constants"; interface AuthDebuggerProps { sseUrl: string; @@ -24,38 +24,58 @@ interface AuthDebuggerProps { updateAuthState: (updates: Partial) => void; } -// Enhanced version of the OAuth client provider specifically for debug flows +// Overrides debug URL and allows saving server OAuth metadata to +// display in debug UI. class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { get redirectUrl(): string { return `${window.location.origin}/oauth/callback/debug`; } + + 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); + } } -const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebuggerProps) => { - // Load client info asynchronously when we have a debug code - useEffect(() => { - const debugCode = sessionStorage.getItem(SESSION_KEYS.DEBUG_CODE); - if (debugCode && sseUrl && authState.oauthStep === "token_request") { - // Load client info asynchronously - const provider = new DebugInspectorOAuthClientProvider(sseUrl); - provider - .clientInformation() - .then((info) => { - updateAuthState({ oauthClientInfo: info || null }); - }) - .catch((error) => { - console.error("Failed to load client information:", error); - }); +const AuthDebugger = ({ + sseUrl, + onBack, + authState, + updateAuthState, +}: AuthDebuggerProps) => { + // Load client info asynchronously when we're at the token_request step + + const validateOAuthMetadata = async ( + provider: DebugInspectorOAuthClientProvider, + ): Promise => { + const metadata = provider.getServerMetadata(); + if (metadata) { + return metadata; } - }, [sseUrl, authState.oauthStep, updateAuthState]); - const validateOAuthMetadata = ( - metadata: OAuthMetadata | null, - ): OAuthMetadata => { - if (!metadata) { - throw new Error("Can't advance without successfully fetching metadata"); + const fetchedMetadata = await discoverOAuthMetadata(sseUrl); + if (!fetchedMetadata) { + throw new Error("Failed to discover OAuth metadata"); } - return metadata; + const parsedMetadata = + await OAuthMetadataSchema.parseAsync(fetchedMetadata); + + return parsedMetadata; }; const validateClientInformation = async ( @@ -108,8 +128,9 @@ const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebugg } const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); updateAuthState({ oauthMetadata: parsedMetadata }); + provider.saveServerMetadata(parsedMetadata); } else if (authState.oauthStep === "metadata_discovery") { - const metadata = validateOAuthMetadata(authState.oauthMetadata); + const metadata = await validateOAuthMetadata(provider); updateAuthState({ oauthStep: "client_registration" }); @@ -127,7 +148,7 @@ const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebugg provider.saveClientInformation(fullInformation); updateAuthState({ oauthClientInfo: fullInformation }); } else if (authState.oauthStep === "client_registration") { - const metadata = validateOAuthMetadata(authState.oauthMetadata); + const metadata = await validateOAuthMetadata(provider); const clientInformation = await validateClientInformation(provider); updateAuthState({ oauthStep: "authorization_redirect" }); try { @@ -158,7 +179,10 @@ const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebugg ); } } else if (authState.oauthStep === "authorization_code") { - if (!authState.authorizationCode || authState.authorizationCode.trim() === "") { + if ( + !authState.authorizationCode || + authState.authorizationCode.trim() === "" + ) { updateAuthState({ validationError: "You need to provide an authorization code", }); @@ -167,7 +191,7 @@ const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebugg updateAuthState({ validationError: null, oauthStep: "token_request" }); } else if (authState.oauthStep === "token_request") { const codeVerifier = provider.codeVerifier(); - const metadata = validateOAuthMetadata(authState.oauthMetadata); + const metadata = await validateOAuthMetadata(provider); const clientInformation = await validateClientInformation(provider); const tokens = await exchangeAuthorization(sseUrl, { @@ -285,6 +309,7 @@ const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebugg }, [authState.statusMessage]); const renderOAuthFlow = useCallback(() => { + const provider = new DebugInspectorOAuthClientProvider(sseUrl); const steps = [ { key: "not_started", @@ -294,14 +319,14 @@ const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebugg { key: "metadata_discovery", label: "Metadata Discovery", - metadata: authState.oauthMetadata && ( + metadata: provider.getServerMetadata() && (
Retrieved OAuth Metadata from {sseUrl} /.well-known/oauth-authorization-server
-              {JSON.stringify(authState.oauthMetadata, null, 2)}
+              {JSON.stringify(provider.getServerMetadata(), null, 2)}
             
), @@ -491,7 +516,9 @@ const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebugg authState.authorizationUrl && ( @@ -499,7 +526,7 @@ const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebugg
); - }, [authState, sseUrl]); + }, [authState, sseUrl, proceedToNextStep, updateAuthState]); return (
@@ -581,4 +608,4 @@ const AuthDebugger = ({ sseUrl, onBack, authState, updateAuthState }: AuthDebugg ); }; -export default AuthDebugger; \ No newline at end of file +export default AuthDebugger; diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 00be15ec..b64ee6cc 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -7,7 +7,7 @@ import { } from "@/utils/oauthUtils.ts"; interface OAuthCallbackProps { - onConnect: (serverUrl: string) => void; + onConnect: (serverUrl: string, authorizationCode?: string) => void; } const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { @@ -50,18 +50,17 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { return notifyError("Missing authorization code"); } - sessionStorage.setItem(SESSION_KEYS.DEBUG_CODE, params.code); + // Instead of storing in sessionStorage, pass the code directly + // to the auth state manager through onConnect + onConnect(serverUrl, params.code); - // Finally, trigger navigation back to auth debugger + // Show success message toast({ title: "Success", description: - "Authorization code received. Please return to the Auth Debugger.", + "Authorization code received. Returning to the Auth Debugger.", variant: "default", }); - - // Call onConnect to navigate back to the auth debugger - onConnect(serverUrl); }; handleCallback().finally(() => { @@ -99,4 +98,4 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { ); }; -export default OAuthDebugCallback; +export default OAuthDebugCallback; \ No newline at end of file diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 7ef31822..529fb4e3 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -8,7 +8,7 @@ import { import { SESSION_KEYS, getServerSpecificKey } from "./constants"; export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor(private serverUrl: string) { + constructor(protected serverUrl: string) { // Save the server URL to session storage sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); } diff --git a/client/src/lib/constants.ts b/client/src/lib/constants.ts index 206864be..4c3e27aa 100644 --- a/client/src/lib/constants.ts +++ b/client/src/lib/constants.ts @@ -6,11 +6,7 @@ export const SESSION_KEYS = { SERVER_URL: "mcp_server_url", TOKENS: "mcp_tokens", CLIENT_INFORMATION: "mcp_client_information", - /** - * Temporary storage for OAuth authorization code during debug flow - * Used when authentication is done in a separate tab - */ - DEBUG_CODE: "mcp_code", + SERVER_METADATA: "mcp_server_metadata", } as const; // Generate server-specific session storage keys From cde4663ca59f15a579697826388907d5e3763fe7 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 13:31:30 +0100 Subject: [PATCH 11/35] sort out displaying debugger --- client/src/App.tsx | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index f62d2534..2f209f5b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -31,7 +31,7 @@ import { useConnection } from "./lib/hooks/useConnection"; import { useDraggablePane } from "./lib/hooks/useDraggablePane"; import { StdErrNotification } from "./lib/notificationTypes"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Bell, @@ -39,12 +39,13 @@ import { FolderTree, Hammer, Hash, + Key, MessageSquare, } from "lucide-react"; import { z } from "zod"; import "./App.css"; -const AuthDebugger = React.lazy(() => import("./components/AuthDebugger")); +import AuthDebugger from "./components/AuthDebugger"; import ConsoleTab from "./components/ConsoleTab"; import HistoryAndNotifications from "./components/History"; import PingTab from "./components/PingTab"; @@ -259,21 +260,25 @@ const App = () => { (serverUrl: string) => { setSseUrl(serverUrl); setTransportType("sse"); + setIsAuthDebuggerVisible(false); void connectMcpServer(); }, [connectMcpServer], ); // Auto-connect to previously saved serverURL after OAuth callback - const onOAuthDebugConnect = useCallback((serverUrl: string, authorizationCode?: string) => { - setIsAuthDebuggerVisible(true); - if (authorizationCode) { - updateAuthState({ - authorizationCode, - oauthStep: "token_request", - }); - } - }, []); + const onOAuthDebugConnect = useCallback( + (serverUrl: string, authorizationCode?: string) => { + setIsAuthDebuggerVisible(true); + if (authorizationCode) { + updateAuthState({ + authorizationCode, + oauthStep: "token_request", + }); + } + }, + [], + ); // Load OAuth tokens when sseUrl changes useEffect(() => { @@ -537,14 +542,14 @@ const App = () => { // Helper component for rendering the AuthDebugger const AuthDebuggerWrapper = () => ( - Loading...
}> + setIsAuthDebuggerVisible(false)} authState={authState} updateAuthState={updateAuthState} /> - + ); // Helper function to render OAuth callback components @@ -659,6 +664,10 @@ const App = () => { Roots + + + Auth +
@@ -796,7 +805,13 @@ const App = () => {
) : isAuthDebuggerVisible ? ( - + (window.location.hash = value)} + > + + ) : (

From 506d907666eacc321eed798e0ca0585ee886f7b7 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 13:33:11 +0100 Subject: [PATCH 12/35] prettier --- client/src/App.tsx | 2 +- client/src/components/OAuthDebugCallback.tsx | 2 +- client/src/lib/auth-types.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 2f209f5b..7b41056c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -268,7 +268,7 @@ const App = () => { // Auto-connect to previously saved serverURL after OAuth callback const onOAuthDebugConnect = useCallback( - (serverUrl: string, authorizationCode?: string) => { + (_serverUrl: string, authorizationCode?: string) => { setIsAuthDebuggerVisible(true); if (authorizationCode) { updateAuthState({ diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index b64ee6cc..70f5564a 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -98,4 +98,4 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { ); }; -export default OAuthDebugCallback; \ No newline at end of file +export default OAuthDebugCallback; diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index 77069124..a580ec9f 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -43,4 +43,4 @@ export class DebugInspectorOAuthClientProvider { get redirectUrl(): string { return `${window.location.origin}/oauth/callback/debug`; } -} \ No newline at end of file +} From f4664c7ff13b6d9b636020369fb5d680f2bc01d0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 13:38:57 +0100 Subject: [PATCH 13/35] cleanup types --- client/src/components/AuthDebugger.tsx | 6 +++--- client/src/lib/auth.ts | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 9ba9e703..b4e3ad26 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from "react"; +import { useCallback } from "react"; import { Button } from "@/components/ui/button"; import { InspectorOAuthClientProvider } from "../lib/auth"; import { @@ -137,7 +137,7 @@ const AuthDebugger = ({ const clientMetadata = provider.clientMetadata; // Add all supported scopes to client registration. if (metadata.scopes_supported) { - clientMetadata["scope"] = metadata.scopes_supported.join(" "); + clientMetadata.scope = metadata.scopes_supported.join(" "); } const fullInformation = await registerClient(sseUrl, { @@ -213,7 +213,7 @@ const AuthDebugger = ({ } finally { updateAuthState({ isInitiatingAuth: false }); } - }, [sseUrl, authState, updateAuthState]); + }, [sseUrl, authState, updateAuthState, validateOAuthMetadata]); const handleStartOAuth = useCallback(async () => { if (!sseUrl) { diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 529fb4e3..2ad319f4 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -4,6 +4,7 @@ import { OAuthClientInformation, OAuthTokens, OAuthTokensSchema, + OAuthClientMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./constants"; @@ -17,7 +18,7 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { return window.location.origin + "/oauth/callback"; } - get clientMetadata() { + get clientMetadata(): OAuthClientMetadata { return { redirect_uris: [this.redirectUrl], token_endpoint_auth_method: "none", From c46df442f8a5434242a891cbcf88615b3fed3e2b Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 13:43:03 +0100 Subject: [PATCH 14/35] fix tests --- .../__tests__/AuthDebugger.test.tsx | 82 +++++++++++++++---- 1 file changed, 64 insertions(+), 18 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index f2e370d6..fc39fcb1 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -77,9 +77,25 @@ Object.defineProperty(window, "location", { }); describe("AuthDebugger", () => { + const defaultAuthState = { + isInitiatingAuth: false, + oauthTokens: null, + loading: false, + oauthStep: "not_started" as const, + oauthMetadata: null, + oauthClientInfo: null, + authorizationUrl: null, + authorizationCode: "", + latestError: null, + statusMessage: null, + validationError: null, + }; + const defaultProps = { sseUrl: "https://example.com", onBack: jest.fn(), + authState: defaultAuthState, + updateAuthState: jest.fn(), }; beforeEach(() => { @@ -97,9 +113,14 @@ describe("AuthDebugger", () => { }); const renderAuthDebugger = (props = {}) => { + const mergedProps = { + ...defaultProps, + ...props, + authState: { ...defaultAuthState, ...props.authState }, + }; return render( - + , ); }; @@ -136,19 +157,21 @@ describe("AuthDebugger", () => { }); it("should show error when OAuth flow is started without sseUrl", async () => { + const updateAuthState = jest.fn(); await act(async () => { - renderAuthDebugger({ sseUrl: "" }); + renderAuthDebugger({ sseUrl: "", updateAuthState }); }); await act(async () => { fireEvent.click(screen.getByText("Guided OAuth Flow")); }); - expect(mockToast).toHaveBeenCalledWith({ - title: "Error", - description: - "Please enter a server URL in the sidebar before authenticating", - variant: "destructive", + expect(updateAuthState).toHaveBeenCalledWith({ + statusMessage: { + type: "error", + message: + "Please enter a server URL in the sidebar before authenticating", + }, }); }); }); @@ -164,7 +187,12 @@ describe("AuthDebugger", () => { }); await act(async () => { - renderAuthDebugger(); + renderAuthDebugger({ + authState: { + ...defaultAuthState, + oauthTokens: mockOAuthTokens + } + }); }); await waitFor(() => { @@ -199,6 +227,7 @@ 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") { @@ -208,16 +237,30 @@ describe("AuthDebugger", () => { }); await act(async () => { - renderAuthDebugger(); + renderAuthDebugger({ + authState: { + ...defaultAuthState, + oauthTokens: mockOAuthTokens + }, + updateAuthState + }); }); await act(async () => { fireEvent.click(screen.getByText("Clear OAuth State")); }); - expect(mockToast).toHaveBeenCalledWith({ - title: "Success", - description: "OAuth tokens cleared successfully", + expect(updateAuthState).toHaveBeenCalledWith({ + oauthTokens: null, + oauthStep: "not_started", + latestError: null, + oauthClientInfo: null, + authorizationCode: "", + validationError: null, + statusMessage: { + type: "success", + message: "OAuth tokens cleared successfully", + }, }); // Verify session storage was cleared @@ -227,13 +270,16 @@ describe("AuthDebugger", () => { describe("OAuth Flow Steps", () => { it("should handle OAuth flow step progression", async () => { + const updateAuthState = jest.fn(); await act(async () => { - renderAuthDebugger(); - }); - - // Start guided flow - await act(async () => { - fireEvent.click(screen.getByText("Guided OAuth Flow")); + renderAuthDebugger({ + updateAuthState, + authState: { + ...defaultAuthState, + isInitiatingAuth: false, // Changed to false so button is enabled + oauthStep: "metadata_discovery" + } + }); }); // Verify metadata discovery step From c3a565f27f48552cf342acc6398bcf78bd1b14c1 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 13:56:18 +0100 Subject: [PATCH 15/35] cleanup comment --- client/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 7b41056c..b0443084 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -144,7 +144,7 @@ const App = () => { >([]); const [isAuthDebuggerVisible, setIsAuthDebuggerVisible] = useState(false); - // Auth debugger state (moved from AuthDebugger component) + // Auth debugger state const [authState, setAuthState] = useState({ isInitiatingAuth: false, oauthTokens: null, From f6053b0dcc754e564ff5bcc1b84444c324cf6382 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 13:58:55 +0100 Subject: [PATCH 16/35] prettier --- .../__tests__/AuthDebugger.test.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index fc39fcb1..6a1be028 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -50,7 +50,7 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ exchangeAuthorization: jest.fn(), })); -// Import mocked functions (removing unused mockAuth) +// Import mocked functions import { discoverOAuthMetadata as mockDiscoverOAuthMetadata, registerClient as mockRegisterClient, @@ -188,10 +188,10 @@ describe("AuthDebugger", () => { await act(async () => { renderAuthDebugger({ - authState: { - ...defaultAuthState, - oauthTokens: mockOAuthTokens - } + authState: { + ...defaultAuthState, + oauthTokens: mockOAuthTokens, + }, }); }); @@ -240,9 +240,9 @@ describe("AuthDebugger", () => { renderAuthDebugger({ authState: { ...defaultAuthState, - oauthTokens: mockOAuthTokens + oauthTokens: mockOAuthTokens, }, - updateAuthState + updateAuthState, }); }); @@ -276,9 +276,9 @@ describe("AuthDebugger", () => { updateAuthState, authState: { ...defaultAuthState, - isInitiatingAuth: false, // Changed to false so button is enabled - oauthStep: "metadata_discovery" - } + isInitiatingAuth: false, // Changed to false so button is enabled + oauthStep: "metadata_discovery", + }, }); }); From 779621e749586f81f71be1fc11b4006acd0e8a51 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 14:11:12 +0100 Subject: [PATCH 17/35] fixup types in tests --- .../__tests__/AuthDebugger.test.tsx | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 6a1be028..21b2d4fc 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -41,7 +41,7 @@ jest.mock("@/hooks/use-toast", () => ({ }), })); -// Mock MCP SDK functions +// Mock MCP SDK functions - must be before imports jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), discoverOAuthMetadata: jest.fn(), @@ -50,14 +50,20 @@ jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ exchangeAuthorization: jest.fn(), })); -// Import mocked functions +// Import the functions to get their types import { - discoverOAuthMetadata as mockDiscoverOAuthMetadata, - registerClient as mockRegisterClient, - startAuthorization as mockStartAuthorization, - exchangeAuthorization as mockExchangeAuthorization, + discoverOAuthMetadata, + registerClient, + startAuthorization, + exchangeAuthorization, } from "@modelcontextprotocol/sdk/client/auth.js"; +// Type the mocked functions properly +const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction; +const mockRegisterClient = registerClient as jest.MockedFunction; +const mockStartAuthorization = startAuthorization as jest.MockedFunction; +const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction; + // Mock Session Storage const sessionStorageMock = { getItem: jest.fn(), @@ -101,22 +107,22 @@ describe("AuthDebugger", () => { beforeEach(() => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - (mockDiscoverOAuthMetadata as jest.Mock).mockResolvedValue( - mockOAuthMetadata, - ); - (mockRegisterClient as jest.Mock).mockResolvedValue(mockOAuthClientInfo); - (mockStartAuthorization as jest.Mock).mockResolvedValue({ + + // Set up mock implementations + mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); + mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); + mockStartAuthorization.mockResolvedValue({ authorizationUrl: new URL("https://oauth.example.com/authorize"), codeVerifier: "test_verifier", }); - (mockExchangeAuthorization as jest.Mock).mockResolvedValue(mockOAuthTokens); + mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); }); - const renderAuthDebugger = (props = {}) => { + const renderAuthDebugger = (props: any = {}) => { const mergedProps = { ...defaultProps, ...props, - authState: { ...defaultAuthState, ...props.authState }, + authState: { ...defaultAuthState, ...(props.authState || {}) }, }; return render( @@ -290,9 +296,7 @@ describe("AuthDebugger", () => { fireEvent.click(screen.getByText("Continue")); }); - expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( - "https://example.com", - ); + expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith("https://example.com"); }); }); }); From 8e8eb41555b2c06118a8bd88b8726a64127d0852 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 14:16:17 +0100 Subject: [PATCH 18/35] prettier --- .../__tests__/AuthDebugger.test.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 21b2d4fc..7b5f6177 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -59,10 +59,18 @@ import { } from "@modelcontextprotocol/sdk/client/auth.js"; // Type the mocked functions properly -const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction; -const mockRegisterClient = registerClient as jest.MockedFunction; -const mockStartAuthorization = startAuthorization as jest.MockedFunction; -const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction; +const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction< + typeof discoverOAuthMetadata +>; +const mockRegisterClient = registerClient as jest.MockedFunction< + typeof registerClient +>; +const mockStartAuthorization = startAuthorization as jest.MockedFunction< + typeof startAuthorization +>; +const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< + typeof exchangeAuthorization +>; // Mock Session Storage const sessionStorageMock = { @@ -107,7 +115,7 @@ describe("AuthDebugger", () => { beforeEach(() => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - + // Set up mock implementations mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); @@ -296,7 +304,9 @@ describe("AuthDebugger", () => { fireEvent.click(screen.getByText("Continue")); }); - expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith("https://example.com"); + expect(mockDiscoverOAuthMetadata).toHaveBeenCalledWith( + "https://example.com", + ); }); }); }); From 4a67d0c76512256db32ec3395b3ce851f65630f6 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 14:29:25 +0100 Subject: [PATCH 19/35] refactor debug to avoid toasting --- client/src/App.tsx | 43 ++++++++++++-------- client/src/components/OAuthDebugCallback.tsx | 37 +++++++---------- 2 files changed, 40 insertions(+), 40 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index b0443084..e03f312b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -266,9 +266,15 @@ const App = () => { [connectMcpServer], ); - // Auto-connect to previously saved serverURL after OAuth callback + // Update OAuth debug state during debug callback const onOAuthDebugConnect = useCallback( - (_serverUrl: string, authorizationCode?: string) => { + ({ + authorizationCode, + errorMsg, + }: { + authorizationCode?: string; + errorMsg?: string; + }) => { setIsAuthDebuggerVisible(true); if (authorizationCode) { updateAuthState({ @@ -276,6 +282,11 @@ const App = () => { oauthStep: "token_request", }); } + if (errorMsg) { + updateAuthState({ + latestError: new Error(errorMsg), + }); + } }, [], ); @@ -553,28 +564,26 @@ const App = () => { ); // Helper function to render OAuth callback components - const renderOAuthCallback = ( - path: string, - onConnect: (serverUrl: string, authorizationCode?: string) => void, - ) => { - const Component = - path === "/oauth/callback" - ? React.lazy(() => import("./components/OAuthCallback")) - : React.lazy(() => import("./components/OAuthDebugCallback")); - + if (window.location.pathname === "/oauth/callback") { + const OAuthCallback = React.lazy( + () => import("./components/OAuthCallback"), + ); return ( Loading...

}> - + ); - }; - - if (window.location.pathname === "/oauth/callback") { - return renderOAuthCallback(window.location.pathname, onOAuthConnect); } if (window.location.pathname === "/oauth/callback/debug") { - return renderOAuthCallback(window.location.pathname, onOAuthDebugConnect); + const OAuthDebugCallback = React.lazy( + () => import("./components/OAuthDebugCallback"), + ); + return ( + Loading...
}> + + + ); } return ( diff --git a/client/src/components/OAuthDebugCallback.tsx b/client/src/components/OAuthDebugCallback.tsx index 70f5564a..88d931c0 100644 --- a/client/src/components/OAuthDebugCallback.tsx +++ b/client/src/components/OAuthDebugCallback.tsx @@ -1,18 +1,21 @@ import { useEffect } from "react"; import { SESSION_KEYS } from "../lib/constants"; -import { useToast } from "@/hooks/use-toast.ts"; import { generateOAuthErrorDescription, parseOAuthCallbackParams, } from "@/utils/oauthUtils.ts"; interface OAuthCallbackProps { - onConnect: (serverUrl: string, authorizationCode?: string) => void; + onConnect: ({ + authorizationCode, + errorMsg, + }: { + authorizationCode?: string; + errorMsg?: string; + }) => void; } const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { - const { toast } = useToast(); - useEffect(() => { let isProcessed = false; @@ -23,16 +26,11 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { } isProcessed = true; - const notifyError = (description: string) => - void toast({ - title: "OAuth Authorization Error", - description, - variant: "destructive", - }); - const params = parseOAuthCallbackParams(window.location.search); if (!params.successful) { - return notifyError(generateOAuthErrorDescription(params)); + const errorMsg = generateOAuthErrorDescription(params); + onConnect({ errorMsg }); + return; } const serverUrl = sessionStorage.getItem(SESSION_KEYS.SERVER_URL); @@ -47,20 +45,13 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { } if (!params.code) { - return notifyError("Missing authorization code"); + onConnect({ errorMsg: "Missing authorization code" }); + return; } // Instead of storing in sessionStorage, pass the code directly // to the auth state manager through onConnect - onConnect(serverUrl, params.code); - - // Show success message - toast({ - title: "Success", - description: - "Authorization code received. Returning to the Auth Debugger.", - variant: "default", - }); + onConnect({ authorizationCode: params.code }); }; handleCallback().finally(() => { @@ -74,7 +65,7 @@ const OAuthDebugCallback = ({ onConnect }: OAuthCallbackProps) => { return () => { isProcessed = true; }; - }, [toast, onConnect]); + }, [onConnect]); const callbackParams = parseOAuthCallbackParams(window.location.search); From cbe63246eadb65960be82cd01665902f2bc77d3c Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 14:45:40 +0100 Subject: [PATCH 20/35] callback shuffling --- client/src/components/AuthDebugger.tsx | 61 ++++++++++++++------------ 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index b4e3ad26..2c9ab1b0 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -60,34 +60,7 @@ const AuthDebugger = ({ }: AuthDebuggerProps) => { // Load client info asynchronously when we're at the token_request step - const validateOAuthMetadata = async ( - provider: DebugInspectorOAuthClientProvider, - ): Promise => { - const metadata = provider.getServerMetadata(); - if (metadata) { - return metadata; - } - - const fetchedMetadata = await discoverOAuthMetadata(sseUrl); - if (!fetchedMetadata) { - throw new Error("Failed to discover OAuth metadata"); - } - const parsedMetadata = - await OAuthMetadataSchema.parseAsync(fetchedMetadata); - - return parsedMetadata; - }; - - const validateClientInformation = async ( - provider: DebugInspectorOAuthClientProvider, - ): Promise => { - const clientInformation = await provider.clientInformation(); - - if (!clientInformation) { - throw new Error("Can't advance without successful client registration"); - } - return clientInformation; - }; + // These functions will be moved inside proceedToNextStep to avoid ESLint warning const startOAuthFlow = useCallback(() => { if (!sseUrl) { @@ -113,6 +86,36 @@ const AuthDebugger = ({ if (!sseUrl) return; const provider = new DebugInspectorOAuthClientProvider(sseUrl); + // Helper functions moved inside useCallback to avoid ESLint warning + const validateOAuthMetadata = async ( + provider: DebugInspectorOAuthClientProvider, + ): Promise => { + const metadata = provider.getServerMetadata(); + if (metadata) { + return metadata; + } + + const fetchedMetadata = await discoverOAuthMetadata(sseUrl); + if (!fetchedMetadata) { + throw new Error("Failed to discover OAuth metadata"); + } + const parsedMetadata = + await OAuthMetadataSchema.parseAsync(fetchedMetadata); + + return parsedMetadata; + }; + + const validateClientInformation = async ( + provider: DebugInspectorOAuthClientProvider, + ): Promise => { + const clientInformation = await provider.clientInformation(); + + if (!clientInformation) { + throw new Error("Can't advance without successful client registration"); + } + return clientInformation; + }; + try { updateAuthState({ isInitiatingAuth: true, @@ -213,7 +216,7 @@ const AuthDebugger = ({ } finally { updateAuthState({ isInitiatingAuth: false }); } - }, [sseUrl, authState, updateAuthState, validateOAuthMetadata]); + }, [sseUrl, authState, updateAuthState]); const handleStartOAuth = useCallback(async () => { if (!sseUrl) { From f986114a8b50f92e3445d0290f82e9ad5b0d084e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 14:47:17 +0100 Subject: [PATCH 21/35] linting --- client/src/components/__tests__/AuthDebugger.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 7b5f6177..12d6b8e9 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -57,6 +57,7 @@ import { startAuthorization, exchangeAuthorization, } from "@modelcontextprotocol/sdk/client/auth.js"; +import { AuthDebuggerState } from "@/lib/auth-types"; // Type the mocked functions properly const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction< @@ -126,7 +127,9 @@ describe("AuthDebugger", () => { mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); }); - const renderAuthDebugger = (props: any = {}) => { + const renderAuthDebugger = ( + props: { authState?: AuthDebuggerState } = {}, + ) => { const mergedProps = { ...defaultProps, ...props, From 10cd3e29114f684d9e0f00328268006f20ccf106 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 14:49:05 +0100 Subject: [PATCH 22/35] types --- client/src/components/AuthDebugger.tsx | 2 +- client/src/components/__tests__/AuthDebugger.test.tsx | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 2c9ab1b0..9c0915e7 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -17,7 +17,7 @@ import { CheckCircle2, Circle, ExternalLink, AlertCircle } from "lucide-react"; import { AuthDebuggerState } from "../lib/auth-types"; import { SESSION_KEYS, getServerSpecificKey } from "../lib/constants"; -interface AuthDebuggerProps { +export interface AuthDebuggerProps { sseUrl: string; onBack: () => void; authState: AuthDebuggerState; diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 12d6b8e9..7d02d24d 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -7,7 +7,7 @@ import { } from "@testing-library/react"; import "@testing-library/jest-dom"; import { describe, it, beforeEach, jest } from "@jest/globals"; -import AuthDebugger from "../AuthDebugger"; +import AuthDebugger, { AuthDebuggerProps } from "../AuthDebugger"; import { TooltipProvider } from "@/components/ui/tooltip"; // Mock OAuth data that matches the schemas @@ -57,7 +57,6 @@ import { startAuthorization, exchangeAuthorization, } from "@modelcontextprotocol/sdk/client/auth.js"; -import { AuthDebuggerState } from "@/lib/auth-types"; // Type the mocked functions properly const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction< @@ -127,9 +126,7 @@ describe("AuthDebugger", () => { mockExchangeAuthorization.mockResolvedValue(mockOAuthTokens); }); - const renderAuthDebugger = ( - props: { authState?: AuthDebuggerState } = {}, - ) => { + const renderAuthDebugger = (props: Partial = {}) => { const mergedProps = { ...defaultProps, ...props, From 50a48956fe6d31eb617eb7b4a0cd31a30a952787 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Thu, 8 May 2025 14:52:19 +0100 Subject: [PATCH 23/35] rm toast in test --- client/src/components/__tests__/AuthDebugger.test.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 7d02d24d..a4ada4ce 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -33,14 +33,6 @@ const mockOAuthClientInfo = { redirect_uris: ["http://localhost:3000/oauth/callback/debug"], }; -// Mock the toast hook -const mockToast = jest.fn(); -jest.mock("@/hooks/use-toast", () => ({ - useToast: () => ({ - toast: mockToast, - }), -})); - // Mock MCP SDK functions - must be before imports jest.mock("@modelcontextprotocol/sdk/client/auth.js", () => ({ auth: jest.fn(), From bf4b8103c09cfb27512138108804bac1240044b0 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 12 May 2025 13:22:26 +0100 Subject: [PATCH 24/35] bump typescript sdk version to 0.11.2 for scope parameter passing --- package-lock.json | 15 +++++++-------- package.json | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index eb373c5b..29166b3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@modelcontextprotocol/inspector-cli": "^0.12.0", "@modelcontextprotocol/inspector-client": "^0.12.0", "@modelcontextprotocol/inspector-server": "^0.12.0", - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.11.2", "concurrently": "^9.0.1", "shell-quote": "^1.8.2", "spawn-rx": "^5.1.2", @@ -42,7 +42,7 @@ "version": "0.12.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.2", + "@modelcontextprotocol/sdk": "^1.11.0", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -65,7 +65,7 @@ "version": "0.12.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.2", + "@modelcontextprotocol/sdk": "^1.11.0", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -2003,10 +2003,9 @@ "link": true }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", - "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", - "license": "MIT", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.2.tgz", + "integrity": "sha512-H9vwztj5OAqHg9GockCQC06k1natgcxWQSRpQcPJf6i5+MWBzfKkRtxGbjQf0X2ihii0ffLZCRGbYV2f2bjNCQ==", "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", @@ -10796,7 +10795,7 @@ "version": "0.12.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.10.2", + "@modelcontextprotocol/sdk": "^1.11.0", "cors": "^2.8.5", "express": "^5.1.0", "ws": "^8.18.0", diff --git a/package.json b/package.json index 5e924091..1a9de6bb 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@modelcontextprotocol/inspector-cli": "^0.12.0", "@modelcontextprotocol/inspector-client": "^0.12.0", "@modelcontextprotocol/inspector-server": "^0.12.0", - "@modelcontextprotocol/sdk": "^1.11.0", + "@modelcontextprotocol/sdk": "^1.11.2", "concurrently": "^9.0.1", "shell-quote": "^1.8.2", "spawn-rx": "^5.1.2", From 70b965f4d885aea119b928988aff42e4b776f0a8 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 12 May 2025 13:22:50 +0100 Subject: [PATCH 25/35] use proper scope handling --- client/src/components/AuthDebugger.tsx | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 9c0915e7..29c2a532 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -155,26 +155,27 @@ const AuthDebugger = ({ const clientInformation = await validateClientInformation(provider); updateAuthState({ oauthStep: "authorization_redirect" }); try { + let scope: string | undefined = undefined; + if (metadata.scopes_supported) { + // Request all supported scopes during debugging + scope = metadata.scopes_supported.join(" "); + } const { authorizationUrl, codeVerifier } = await startAuthorization( sseUrl, { metadata, clientInformation, redirectUrl: provider.redirectUrl, + scope, }, ); provider.saveCodeVerifier(codeVerifier); - if (metadata.scopes_supported) { - const url = new URL(authorizationUrl.toString()); - url.searchParams.set("scope", metadata.scopes_supported.join(" ")); - updateAuthState({ authorizationUrl: url.toString() }); - } else { - updateAuthState({ authorizationUrl: authorizationUrl.toString() }); - } - - updateAuthState({ oauthStep: "authorization_code" }); + updateAuthState({ + authorizationUrl: authorizationUrl.toString(), + oauthStep: "authorization_code", + }); } catch (error) { console.error("OAuth flow step error:", error); throw new Error( From 415ca4c80b1556aa9e42f55e4cfa92478811d9cb Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 12 May 2025 13:51:52 +0100 Subject: [PATCH 26/35] test scope parameter passing --- .../__tests__/AuthDebugger.test.tsx | 91 +++++++++++++++++-- 1 file changed, 84 insertions(+), 7 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index a4ada4ce..8968c37e 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -9,8 +9,8 @@ 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"; -// Mock OAuth data that matches the schemas const mockOAuthTokens = { access_token: "test_access_token", token_type: "Bearer", @@ -49,6 +49,7 @@ import { startAuthorization, exchangeAuthorization, } from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; // Type the mocked functions properly const mockDiscoverOAuthMetadata = discoverOAuthMetadata as jest.MockedFunction< @@ -64,7 +65,6 @@ const mockExchangeAuthorization = exchangeAuthorization as jest.MockedFunction< typeof exchangeAuthorization >; -// Mock Session Storage const sessionStorageMock = { getItem: jest.fn(), setItem: jest.fn(), @@ -75,7 +75,6 @@ Object.defineProperty(window, "sessionStorage", { value: sessionStorageMock, }); -// Mock the location.origin Object.defineProperty(window, "location", { value: { origin: "http://localhost:3000", @@ -108,12 +107,19 @@ describe("AuthDebugger", () => { jest.clearAllMocks(); sessionStorageMock.getItem.mockReturnValue(null); - // Set up mock implementations mockDiscoverOAuthMetadata.mockResolvedValue(mockOAuthMetadata); mockRegisterClient.mockResolvedValue(mockOAuthClientInfo); - mockStartAuthorization.mockResolvedValue({ - authorizationUrl: new URL("https://oauth.example.com/authorize"), - codeVerifier: "test_verifier", + 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); }); @@ -300,5 +306,76 @@ describe("AuthDebugger", () => { "https://example.com", ); }); + + // 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; + }); + + await act(async () => { + renderAuthDebugger({ + updateAuthState, + authState: { + ...defaultAuthState, + isInitiatingAuth: false, + oauthStep: "client_registration", + oauthMetadata: metadata, + oauthClientInfo: mockOAuthClientInfo, + }, + }); + }); + + // 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.stringContaining("scope="), + }), + ); + }); + }); + + it("should not 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.not.stringContaining("scope="), + }), + ); + }); + }); }); }); From fddcf8f886a56574aadfea9c0eb40818cd4d0391 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 09:24:29 +0100 Subject: [PATCH 27/35] move functions and s/sseUrl/serverUrl/ --- client/src/App.tsx | 2 +- client/src/components/AuthDebugger.tsx | 106 +++++++++--------- .../__tests__/AuthDebugger.test.tsx | 2 +- client/src/lib/auth.ts | 2 +- 4 files changed, 56 insertions(+), 56 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 33658030..3f40e2bb 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -530,7 +530,7 @@ const App = () => { const AuthDebuggerWrapper = () => ( setIsAuthDebuggerVisible(false)} authState={authState} updateAuthState={updateAuthState} diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 29c2a532..338ae5ed 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -18,7 +18,7 @@ import { AuthDebuggerState } from "../lib/auth-types"; import { SESSION_KEYS, getServerSpecificKey } from "../lib/constants"; export interface AuthDebuggerProps { - sseUrl: string; + serverUrl: string; onBack: () => void; authState: AuthDebuggerState; updateAuthState: (updates: Partial) => void; @@ -52,18 +52,44 @@ class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { } } +const validateOAuthMetadata = async ( + provider: DebugInspectorOAuthClientProvider, +): Promise => { + const metadata = provider.getServerMetadata(); + if (metadata) { + return metadata; + } + + const fetchedMetadata = await discoverOAuthMetadata(provider.serverUrl); + if (!fetchedMetadata) { + throw new Error("Failed to discover OAuth metadata"); + } + const parsedMetadata = await OAuthMetadataSchema.parseAsync(fetchedMetadata); + + return parsedMetadata; +}; + +const validateClientInformation = async ( + provider: DebugInspectorOAuthClientProvider, +): Promise => { + const clientInformation = await provider.clientInformation(); + + if (!clientInformation) { + throw new Error("Can't advance without successful client registration"); + } + return clientInformation; +}; + const AuthDebugger = ({ - sseUrl, + serverUrl: serverUrl, onBack, authState, updateAuthState, }: AuthDebuggerProps) => { // Load client info asynchronously when we're at the token_request step - // These functions will be moved inside proceedToNextStep to avoid ESLint warning - const startOAuthFlow = useCallback(() => { - if (!sseUrl) { + if (!serverUrl) { updateAuthState({ statusMessage: { type: "error", @@ -80,41 +106,11 @@ const AuthDebugger = ({ statusMessage: null, latestError: null, }); - }, [sseUrl, updateAuthState]); + }, [serverUrl, updateAuthState]); const proceedToNextStep = useCallback(async () => { - if (!sseUrl) return; - const provider = new DebugInspectorOAuthClientProvider(sseUrl); - - // Helper functions moved inside useCallback to avoid ESLint warning - const validateOAuthMetadata = async ( - provider: DebugInspectorOAuthClientProvider, - ): Promise => { - const metadata = provider.getServerMetadata(); - if (metadata) { - return metadata; - } - - const fetchedMetadata = await discoverOAuthMetadata(sseUrl); - if (!fetchedMetadata) { - throw new Error("Failed to discover OAuth metadata"); - } - const parsedMetadata = - await OAuthMetadataSchema.parseAsync(fetchedMetadata); - - return parsedMetadata; - }; - - const validateClientInformation = async ( - provider: DebugInspectorOAuthClientProvider, - ): Promise => { - const clientInformation = await provider.clientInformation(); - - if (!clientInformation) { - throw new Error("Can't advance without successful client registration"); - } - return clientInformation; - }; + if (!serverUrl) return; + const provider = new DebugInspectorOAuthClientProvider(serverUrl); try { updateAuthState({ @@ -125,7 +121,7 @@ const AuthDebugger = ({ if (authState.oauthStep === "not_started") { updateAuthState({ oauthStep: "metadata_discovery" }); - const metadata = await discoverOAuthMetadata(sseUrl); + const metadata = await discoverOAuthMetadata(serverUrl); if (!metadata) { throw new Error("Failed to discover OAuth metadata"); } @@ -143,7 +139,7 @@ const AuthDebugger = ({ clientMetadata.scope = metadata.scopes_supported.join(" "); } - const fullInformation = await registerClient(sseUrl, { + const fullInformation = await registerClient(serverUrl, { metadata, clientMetadata, }); @@ -161,7 +157,7 @@ const AuthDebugger = ({ scope = metadata.scopes_supported.join(" "); } const { authorizationUrl, codeVerifier } = await startAuthorization( - sseUrl, + serverUrl, { metadata, clientInformation, @@ -198,7 +194,7 @@ const AuthDebugger = ({ const metadata = await validateOAuthMetadata(provider); const clientInformation = await validateClientInformation(provider); - const tokens = await exchangeAuthorization(sseUrl, { + const tokens = await exchangeAuthorization(serverUrl, { metadata, clientInformation, authorizationCode: authState.authorizationCode, @@ -217,10 +213,10 @@ const AuthDebugger = ({ } finally { updateAuthState({ isInitiatingAuth: false }); } - }, [sseUrl, authState, updateAuthState]); + }, [serverUrl, authState, updateAuthState]); const handleStartOAuth = useCallback(async () => { - if (!sseUrl) { + if (!serverUrl) { updateAuthState({ statusMessage: { type: "error", @@ -233,8 +229,10 @@ const AuthDebugger = ({ updateAuthState({ isInitiatingAuth: true, statusMessage: null }); try { - const serverAuthProvider = new DebugInspectorOAuthClientProvider(sseUrl); - await auth(serverAuthProvider, { serverUrl: sseUrl }); + const serverAuthProvider = new DebugInspectorOAuthClientProvider( + serverUrl, + ); + await auth(serverAuthProvider, { serverUrl: serverUrl }); updateAuthState({ statusMessage: { type: "info", @@ -252,11 +250,13 @@ const AuthDebugger = ({ } finally { updateAuthState({ isInitiatingAuth: false }); } - }, [sseUrl, updateAuthState]); + }, [serverUrl, updateAuthState]); const handleClearOAuth = useCallback(() => { - if (sseUrl) { - const serverAuthProvider = new DebugInspectorOAuthClientProvider(sseUrl); + if (serverUrl) { + const serverAuthProvider = new DebugInspectorOAuthClientProvider( + serverUrl, + ); serverAuthProvider.clear(); updateAuthState({ oauthTokens: null, @@ -276,7 +276,7 @@ const AuthDebugger = ({ updateAuthState({ statusMessage: null }); }, 3000); } - }, [sseUrl, updateAuthState]); + }, [serverUrl, updateAuthState]); const renderStatusMessage = useCallback(() => { if (!authState.statusMessage) return null; @@ -313,7 +313,7 @@ const AuthDebugger = ({ }, [authState.statusMessage]); const renderOAuthFlow = useCallback(() => { - const provider = new DebugInspectorOAuthClientProvider(sseUrl); + const provider = new DebugInspectorOAuthClientProvider(serverUrl); const steps = [ { key: "not_started", @@ -326,7 +326,7 @@ const AuthDebugger = ({ metadata: provider.getServerMetadata() && (
- Retrieved OAuth Metadata from {sseUrl} + Retrieved OAuth Metadata from {serverUrl} /.well-known/oauth-authorization-server
@@ -530,7 +530,7 @@ const AuthDebugger = ({
         
); - }, [authState, sseUrl, proceedToNextStep, updateAuthState]); + }, [authState, serverUrl, proceedToNextStep, updateAuthState]); return (
diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 8968c37e..33670b5b 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -171,7 +171,7 @@ describe("AuthDebugger", () => { it("should show error when OAuth flow is started without sseUrl", async () => { const updateAuthState = jest.fn(); await act(async () => { - renderAuthDebugger({ sseUrl: "", updateAuthState }); + renderAuthDebugger({ serverUrl: "", updateAuthState }); }); await act(async () => { diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 2ad319f4..f8169fc8 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -9,7 +9,7 @@ import { import { SESSION_KEYS, getServerSpecificKey } from "./constants"; export class InspectorOAuthClientProvider implements OAuthClientProvider { - constructor(protected serverUrl: string) { + constructor(public serverUrl: string) { // Save the server URL to session storage sessionStorage.setItem(SESSION_KEYS.SERVER_URL, serverUrl); } From 79ef9ab1d0defec3d6366ddeb4a64d340b219467 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 09:29:59 +0100 Subject: [PATCH 28/35] extract status message into component --- client/src/components/AuthDebugger.tsx | 78 ++++++++++++++------------ 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 338ae5ed..21836332 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -24,6 +24,46 @@ export interface AuthDebuggerProps { updateAuthState: (updates: Partial) => void; } +interface StatusMessageProps { + message: { type: "error" | "success" | "info"; message: string }; +} + +const StatusMessage = ({ message }: StatusMessageProps) => { + let bgColor: string; + let textColor: string; + let borderColor: string; + + switch (message.type) { + case "error": + bgColor = "bg-red-50"; + textColor = "text-red-700"; + borderColor = "border-red-200"; + break; + case "success": + bgColor = "bg-green-50"; + textColor = "text-green-700"; + borderColor = "border-green-200"; + break; + case "info": + default: + bgColor = "bg-blue-50"; + textColor = "text-blue-700"; + borderColor = "border-blue-200"; + break; + } + + return ( +
+
+ +

{message.message}

+
+
+ ); +}; + // Overrides debug URL and allows saving server OAuth metadata to // display in debug UI. class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { @@ -278,40 +318,6 @@ const AuthDebugger = ({ } }, [serverUrl, updateAuthState]); - const renderStatusMessage = useCallback(() => { - if (!authState.statusMessage) return null; - - const bgColor = - authState.statusMessage.type === "error" - ? "bg-red-50" - : authState.statusMessage.type === "success" - ? "bg-green-50" - : "bg-blue-50"; - const textColor = - authState.statusMessage.type === "error" - ? "text-red-700" - : authState.statusMessage.type === "success" - ? "text-green-700" - : "text-blue-700"; - const borderColor = - authState.statusMessage.type === "error" - ? "border-red-200" - : authState.statusMessage.type === "success" - ? "border-green-200" - : "border-blue-200"; - - return ( -
-
- -

{authState.statusMessage.message}

-
-
- ); - }, [authState.statusMessage]); - const renderOAuthFlow = useCallback(() => { const provider = new DebugInspectorOAuthClientProvider(serverUrl); const steps = [ @@ -554,7 +560,9 @@ const AuthDebugger = ({ Use OAuth to securely authenticate with the MCP server.

- {renderStatusMessage()} + {authState.statusMessage && ( + + )} {authState.loading ? (

Loading authentication status...

From dc875a0124c70e3115a5dd47be26cd30dd1a0eab Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 10:05:29 +0100 Subject: [PATCH 29/35] refactor progress and steps into components --- client/src/components/AuthDebugger.tsx | 261 +------------------ client/src/components/OAuthFlowProgress.tsx | 267 ++++++++++++++++++++ client/src/lib/auth-types.ts | 7 - client/src/lib/auth.ts | 29 +++ 4 files changed, 305 insertions(+), 259 deletions(-) create mode 100644 client/src/components/OAuthFlowProgress.tsx diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index 21836332..ec563848 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -1,6 +1,6 @@ import { useCallback } from "react"; import { Button } from "@/components/ui/button"; -import { InspectorOAuthClientProvider } from "../lib/auth"; +import { DebugInspectorOAuthClientProvider } from "../lib/auth"; import { auth, discoverOAuthMetadata, @@ -13,9 +13,9 @@ import { OAuthMetadata, OAuthClientInformation, } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { CheckCircle2, Circle, ExternalLink, AlertCircle } from "lucide-react"; +import { AlertCircle } from "lucide-react"; import { AuthDebuggerState } from "../lib/auth-types"; -import { SESSION_KEYS, getServerSpecificKey } from "../lib/constants"; +import { OAuthFlowProgress } from "./OAuthFlowProgress"; export interface AuthDebuggerProps { serverUrl: string; @@ -64,34 +64,6 @@ const StatusMessage = ({ message }: StatusMessageProps) => { ); }; -// Overrides debug URL and allows saving server OAuth metadata to -// display in debug UI. -class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { - get redirectUrl(): string { - return `${window.location.origin}/oauth/callback/debug`; - } - - 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); - } -} - const validateOAuthMetadata = async ( provider: DebugInspectorOAuthClientProvider, ): Promise => { @@ -318,226 +290,6 @@ const AuthDebugger = ({ } }, [serverUrl, updateAuthState]); - const renderOAuthFlow = useCallback(() => { - const provider = new DebugInspectorOAuthClientProvider(serverUrl); - const steps = [ - { - key: "not_started", - label: "Starting OAuth Flow", - metadata: null, - }, - { - key: "metadata_discovery", - label: "Metadata Discovery", - metadata: provider.getServerMetadata() && ( -
- - Retrieved OAuth Metadata from {serverUrl} - /.well-known/oauth-authorization-server - -
-              {JSON.stringify(provider.getServerMetadata(), null, 2)}
-            
-
- ), - }, - { - key: "client_registration", - label: "Client Registration", - metadata: authState.oauthClientInfo && ( -
- - Registered Client Information - -
-              {JSON.stringify(authState.oauthClientInfo, null, 2)}
-            
-
- ), - }, - { - key: "authorization_redirect", - label: "Preparing Authorization", - metadata: authState.authorizationUrl && ( -
-

Authorization URL:

-
-

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

-
- ), - }, - { - key: "authorization_code", - label: "Request Authorization and acquire authorization code", - metadata: ( -
- -
- { - 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. -

-
- ), - }, - { - key: "token_request", - label: "Token Request", - metadata: authState.oauthMetadata && ( -
- - Token Request Details - -
-

Token Endpoint:

- - {authState.oauthMetadata.token_endpoint} - -
-
- ), - }, - { - key: "complete", - label: "Authentication Complete", - metadata: 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)}
-            
-
- ), - }, - ]; - - return ( -
-

OAuth Flow Progress

-

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

- -
- {steps.map((step, idx) => { - const currentStepIdx = steps.findIndex( - (s) => s.key === authState.oauthStep, - ); - const isComplete = idx <= currentStepIdx; - const isCurrent = step.key === authState.oauthStep; - - return ( -
-
- {isComplete ? ( - - ) : ( - - )} - - {step.label} - -
- - {/* Show step metadata if current step and metadata exists */} - {(isCurrent || isComplete) && step.metadata && ( -
{step.metadata}
- )} - - {/* Display error if current step and an error exists */} - {isCurrent && authState.latestError && ( -
-

Error:

-

- {authState.latestError.message} -

-
- )} -
- ); - })} -
- -
- {authState.oauthStep !== "complete" && ( - - )} - - {authState.oauthStep === "authorization_redirect" && - authState.authorizationUrl && ( - - )} -
-
- ); - }, [authState, serverUrl, proceedToNextStep, updateAuthState]); - return (
@@ -612,7 +364,12 @@ const AuthDebugger = ({ )}
- {renderOAuthFlow()} +
diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx new file mode 100644 index 00000000..5d656761 --- /dev/null +++ b/client/src/components/OAuthFlowProgress.tsx @@ -0,0 +1,267 @@ +import { AuthDebuggerState, OAuthStep } from "@/lib/auth-types"; +import { CheckCircle2, Circle, ExternalLink } from "lucide-react"; +import { Button } from "./ui/button"; +import { DebugInspectorOAuthClientProvider } from "@/lib/auth"; + +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; +} + +export const OAuthFlowProgress = ({ + serverUrl, + authState, + updateAuthState, + proceedToNextStep, +}: OAuthFlowProgressProps) => { + const provider = new DebugInspectorOAuthClientProvider(serverUrl); + + // Calculate step states + const steps: Array = [ + "not_started", + "metadata_discovery", + "client_registration", + "authorization_redirect", + "authorization_code", + "token_request", + "complete", + ]; + const currentStepIdx = steps.findIndex((s) => s === authState.oauthStep); + + // Helper to get step props + const getStepProps = (stepName: string, stepIndex: number) => ({ + isComplete: currentStepIdx >= stepIndex, + 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. +

+ +
+ + + + {provider.getServerMetadata() && ( +
+ + Retrieved OAuth Metadata from {serverUrl} + /.well-known/oauth-authorization-server + +
+                {JSON.stringify(provider.getServerMetadata(), null, 2)}
+              
+
+ )} +
+ + + {authState.oauthClientInfo && ( +
+ + Registered Client Information + +
+                {JSON.stringify(authState.oauthClientInfo, null, 2)}
+              
+
+ )} +
+ + + {authState.authorizationUrl && ( +
+

Authorization URL:

+
+

+ {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/lib/auth-types.ts b/client/src/lib/auth-types.ts index a580ec9f..c1a436fa 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -37,10 +37,3 @@ export interface AuthDebuggerState { statusMessage: StatusMessage | null; validationError: string | null; } - -// Enhanced version of the OAuth client provider specifically for debug flows -export class DebugInspectorOAuthClientProvider { - get redirectUrl(): string { - return `${window.location.origin}/oauth/callback/debug`; - } -} diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index f8169fc8..7a29d7e2 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -5,6 +5,7 @@ import { OAuthTokens, OAuthTokensSchema, OAuthClientMetadata, + OAuthMetadata, } from "@modelcontextprotocol/sdk/shared/auth.js"; import { SESSION_KEYS, getServerSpecificKey } from "./constants"; @@ -102,3 +103,31 @@ export class InspectorOAuthClientProvider implements OAuthClientProvider { ); } } + +// Overrides debug URL and allows saving server OAuth metadata to +// display in debug UI. +export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvider { + get redirectUrl(): string { + return `${window.location.origin}/oauth/callback/debug`; + } + + 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); + } +} From 0824277d5c3888b7a8e34088d0a331cd94e66c0f Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 10:07:22 +0100 Subject: [PATCH 30/35] fix test --- client/src/components/__tests__/AuthDebugger.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index 33670b5b..d5840212 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -97,7 +97,7 @@ describe("AuthDebugger", () => { }; const defaultProps = { - sseUrl: "https://example.com", + serverUrl: "https://example.com", onBack: jest.fn(), authState: defaultAuthState, updateAuthState: jest.fn(), From 640746569be16b36558a281c91778a46aceaed7e Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 10:12:01 +0100 Subject: [PATCH 31/35] rename quick handler --- client/src/components/AuthDebugger.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/client/src/components/AuthDebugger.tsx b/client/src/components/AuthDebugger.tsx index ec563848..321d4423 100644 --- a/client/src/components/AuthDebugger.tsx +++ b/client/src/components/AuthDebugger.tsx @@ -98,8 +98,6 @@ const AuthDebugger = ({ authState, updateAuthState, }: AuthDebuggerProps) => { - // Load client info asynchronously when we're at the token_request step - const startOAuthFlow = useCallback(() => { if (!serverUrl) { updateAuthState({ @@ -227,7 +225,7 @@ const AuthDebugger = ({ } }, [serverUrl, authState, updateAuthState]); - const handleStartOAuth = useCallback(async () => { + const handleQuickOAuth = useCallback(async () => { if (!serverUrl) { updateAuthState({ statusMessage: { @@ -341,7 +339,7 @@ const AuthDebugger = ({ + <> + + )} {authState.oauthStep === "authorization_redirect" && diff --git a/client/src/lib/auth-types.ts b/client/src/lib/auth-types.ts index c1a436fa..ef32601a 100644 --- a/client/src/lib/auth-types.ts +++ b/client/src/lib/auth-types.ts @@ -7,7 +7,6 @@ import { // OAuth flow steps export type OAuthStep = - | "not_started" | "metadata_discovery" | "client_registration" | "authorization_redirect" diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 7a29d7e2..3e3516e0 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -130,4 +130,11 @@ export class DebugInspectorOAuthClientProvider extends InspectorOAuthClientProvi } return JSON.parse(metadata); } + + clear() { + super.clear(); + sessionStorage.removeItem( + getServerSpecificKey(SESSION_KEYS.SERVER_METADATA, this.serverUrl), + ); + } } From 3ec4069071ea11ba80863211e11ae3929b69cc34 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 11:04:55 +0100 Subject: [PATCH 33/35] last step complete --- client/src/components/OAuthFlowProgress.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/src/components/OAuthFlowProgress.tsx b/client/src/components/OAuthFlowProgress.tsx index 91b244ff..f604fc73 100644 --- a/client/src/components/OAuthFlowProgress.tsx +++ b/client/src/components/OAuthFlowProgress.tsx @@ -74,7 +74,9 @@ export const OAuthFlowProgress = ({ // Helper to get step props const getStepProps = (stepName: OAuthStep) => ({ - isComplete: currentStepIdx > steps.indexOf(stepName), + isComplete: + currentStepIdx > steps.indexOf(stepName) || + currentStepIdx === steps.length - 1, // last step is "complete" isCurrent: authState.oauthStep === stepName, error: authState.oauthStep === stepName ? authState.latestError : null, }); From 78c9c21fa0d51ba7ab4d969401f11c7024037e86 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 11:13:34 +0100 Subject: [PATCH 34/35] add state machine --- client/src/lib/oauth-state-machine.ts | 193 ++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 client/src/lib/oauth-state-machine.ts diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts new file mode 100644 index 00000000..f9dd4024 --- /dev/null +++ b/client/src/lib/oauth-state-machine.ts @@ -0,0 +1,193 @@ +import { OAuthStep, AuthDebuggerState } from "./auth-types"; +import { DebugInspectorOAuthClientProvider } from "./auth"; +import { + discoverOAuthMetadata, + registerClient, + startAuthorization, + exchangeAuthorization, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthMetadataSchema } from "@modelcontextprotocol/sdk/shared/auth.js"; + +export interface StateMachineContext { + state: AuthDebuggerState; + serverUrl: string; + provider: DebugInspectorOAuthClientProvider; + updateState: (updates: Partial) => void; +} + +export interface StateTransition { + canTransition: (context: StateMachineContext) => Promise; + execute: (context: StateMachineContext) => Promise; + nextStep: OAuthStep; +} + +// State machine transitions +export const oauthTransitions: Record = { + not_started: { + canTransition: async () => true, + execute: async (context) => { + context.updateState({ + oauthStep: "metadata_discovery", + statusMessage: null, + latestError: null, + }); + }, + nextStep: "metadata_discovery", + }, + + metadata_discovery: { + canTransition: async () => true, + execute: async (context) => { + const metadata = await discoverOAuthMetadata(context.serverUrl); + if (!metadata) { + throw new Error("Failed to discover OAuth metadata"); + } + const parsedMetadata = await OAuthMetadataSchema.parseAsync(metadata); + context.provider.saveServerMetadata(parsedMetadata); + context.updateState({ + oauthMetadata: parsedMetadata, + oauthStep: "client_registration", + }); + }, + nextStep: "client_registration", + }, + + client_registration: { + canTransition: async (context) => !!context.state.oauthMetadata, + execute: async (context) => { + const metadata = context.state.oauthMetadata!; + const clientMetadata = context.provider.clientMetadata; + + // Add all supported scopes to client registration + if (metadata.scopes_supported) { + clientMetadata.scope = metadata.scopes_supported.join(" "); + } + + const fullInformation = await registerClient(context.serverUrl, { + metadata, + clientMetadata, + }); + + context.provider.saveClientInformation(fullInformation); + context.updateState({ + oauthClientInfo: fullInformation, + oauthStep: "authorization_redirect", + }); + }, + nextStep: "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!; + + let scope: string | undefined = undefined; + if (metadata.scopes_supported) { + scope = metadata.scopes_supported.join(" "); + } + + const { authorizationUrl, codeVerifier } = await startAuthorization( + context.serverUrl, + { + metadata, + clientInformation, + redirectUrl: context.provider.redirectUrl, + scope, + }, + ); + + context.provider.saveCodeVerifier(codeVerifier); + context.updateState({ + authorizationUrl: authorizationUrl.toString(), + oauthStep: "authorization_code", + }); + }, + nextStep: "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", + }); + }, + nextStep: "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, + }); + + context.provider.saveTokens(tokens); + context.updateState({ + oauthTokens: tokens, + oauthStep: "complete", + }); + }, + nextStep: "complete", + }, + + complete: { + canTransition: () => false, + execute: async () => { + // No-op for complete state + }, + nextStep: "complete", + }, +}; + +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); + } +} From adaa02375785bc353a97b096f0e51e2fb5a99b51 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Tue, 13 May 2025 11:18:58 +0100 Subject: [PATCH 35/35] test and types --- .../src/components/__tests__/AuthDebugger.test.tsx | 7 ++++--- client/src/lib/oauth-state-machine.ts | 14 +------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/client/src/components/__tests__/AuthDebugger.test.tsx b/client/src/components/__tests__/AuthDebugger.test.tsx index d5840212..469c1ba1 100644 --- a/client/src/components/__tests__/AuthDebugger.test.tsx +++ b/client/src/components/__tests__/AuthDebugger.test.tsx @@ -86,7 +86,7 @@ describe("AuthDebugger", () => { isInitiatingAuth: false, oauthTokens: null, loading: false, - oauthStep: "not_started" as const, + oauthStep: "metadata_discovery" as const, oauthMetadata: null, oauthClientInfo: null, authorizationUrl: null, @@ -264,9 +264,10 @@ describe("AuthDebugger", () => { expect(updateAuthState).toHaveBeenCalledWith({ oauthTokens: null, - oauthStep: "not_started", + oauthStep: "metadata_discovery", latestError: null, oauthClientInfo: null, + oauthMetadata: null, authorizationCode: "", validationError: null, statusMessage: { @@ -330,7 +331,7 @@ describe("AuthDebugger", () => { authState: { ...defaultAuthState, isInitiatingAuth: false, - oauthStep: "client_registration", + oauthStep: "authorization_redirect", oauthMetadata: metadata, oauthClientInfo: mockOAuthClientInfo, }, diff --git a/client/src/lib/oauth-state-machine.ts b/client/src/lib/oauth-state-machine.ts index f9dd4024..0678229c 100644 --- a/client/src/lib/oauth-state-machine.ts +++ b/client/src/lib/oauth-state-machine.ts @@ -23,18 +23,6 @@ export interface StateTransition { // State machine transitions export const oauthTransitions: Record = { - not_started: { - canTransition: async () => true, - execute: async (context) => { - context.updateState({ - oauthStep: "metadata_discovery", - statusMessage: null, - latestError: null, - }); - }, - nextStep: "metadata_discovery", - }, - metadata_discovery: { canTransition: async () => true, execute: async (context) => { @@ -160,7 +148,7 @@ export const oauthTransitions: Record = { }, complete: { - canTransition: () => false, + canTransition: async () => false, execute: async () => { // No-op for complete state },