From 10972b0ca5a3b2fb469879ca6fe14fb157a78201 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Tue, 24 Jun 2025 15:59:57 +1000 Subject: [PATCH 1/2] feat: add preventAutoAuth option to prevent automatic authentication popups - Add preventAutoAuth option to UseMcpOptions to control automatic popup behavior - Introduce pending_auth state for when auth is required but auto-popup is prevented - Enhance authentication flow to check for existing tokens before triggering auth - Add prepareAuthorizationUrl() helper method in BrowserOAuthClientProvider - Update authenticate() method to handle pending_auth state transitions - Improve chat UI components to handle new authentication states gracefully - Add clear call-to-action UI with primary popup button and fallback new tab link - Maintain backward compatibility - existing code continues to work unchanged This prevents browser popup blockers from interfering with authentication flows when users return to pages with MCP servers, providing better UX and control. --- .../chat-ui/src/components/McpServerModal.tsx | 48 ++++++++++------- .../chat-ui/src/components/McpServers.tsx | 42 ++++++++++----- src/auth/browser-provider.ts | 19 +++++-- src/react/types.ts | 5 +- src/react/useMcp.ts | 54 +++++++++++++++++-- 5 files changed, 127 insertions(+), 41 deletions(-) diff --git a/examples/chat-ui/src/components/McpServerModal.tsx b/examples/chat-ui/src/components/McpServerModal.tsx index 9c95031..cd4acf9 100644 --- a/examples/chat-ui/src/components/McpServerModal.tsx +++ b/examples/chat-ui/src/components/McpServerModal.tsx @@ -11,13 +11,7 @@ interface McpServer { } // MCP Connection wrapper for a single server -function McpConnection({ - server, - onConnectionUpdate, -}: { - server: McpServer - onConnectionUpdate: (serverId: string, data: any) => void -}) { +function McpConnection({ server, onConnectionUpdate }: { server: McpServer; onConnectionUpdate: (serverId: string, data: any) => void }) { // Use the MCP hook with the server URL const connection = useMcp({ url: server.url, @@ -25,6 +19,7 @@ function McpConnection({ autoRetry: false, popupFeatures: 'width=500,height=600,resizable=yes,scrollbars=yes', transportType: server.transportType, + preventAutoAuth: true, // Prevent automatic popups on page load }) // Update parent component with connection data @@ -206,6 +201,8 @@ const McpServerModal: React.FC = ({ isOpen, onClose, onTool switch (state) { case 'discovering': return Discovering + case 'pending_auth': + return Authentication Required case 'authenticating': return Authenticating case 'connecting': @@ -299,18 +296,31 @@ const McpServerModal: React.FC = ({ isOpen, onClose, onTool
{error}
)} - {authUrl && ( -
-

Authentication required. Please click the link below:

- handleManualAuth(server.id)} - > - Authenticate in new window - + {(state === 'pending_auth' || authUrl) && ( +
+

+ {state === 'pending_auth' + ? 'Authentication is required to connect to this server.' + : 'Authentication popup was blocked. You can open the authentication page manually:'} +

+
+ + {authUrl && ( + + Or open in new tab instead + + )} +
)} diff --git a/examples/chat-ui/src/components/McpServers.tsx b/examples/chat-ui/src/components/McpServers.tsx index fa734ab..037cce7 100644 --- a/examples/chat-ui/src/components/McpServers.tsx +++ b/examples/chat-ui/src/components/McpServers.tsx @@ -10,6 +10,7 @@ function McpConnection({ serverUrl, onConnectionUpdate }: { serverUrl: string; o debug: true, autoRetry: false, popupFeatures: 'width=500,height=600,resizable=yes,scrollbars=yes', + preventAutoAuth: true, // Prevent automatic popups on page load }) // Update parent component with connection data @@ -103,6 +104,8 @@ export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[]) switch (state) { case 'discovering': return Discovering + case 'pending_auth': + return Authentication Required case 'authenticating': return Authenticating case 'connecting': @@ -184,19 +187,32 @@ export function McpServers({ onToolsUpdate }: { onToolsUpdate?: (tools: Tool[]) )}
- {/* Authentication Link if needed */} - {authUrl && ( -
-

Authentication required. Please click the link below:

- - Authenticate in new window - + {/* Authentication Action for pending_auth or existing authUrl */} + {(state === 'pending_auth' || authUrl) && ( +
+

+ {state === 'pending_auth' + ? 'Authentication is required to connect to this server.' + : 'Authentication popup was blocked. You can open the authentication page manually:'} +

+
+ + {authUrl && ( + + Or open in new tab instead + + )} +
)} diff --git a/src/auth/browser-provider.ts b/src/auth/browser-provider.ts index f644a0e..41c4af6 100644 --- a/src/auth/browser-provider.ts +++ b/src/auth/browser-provider.ts @@ -117,11 +117,12 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { } /** - * Redirects the user agent to the authorization URL, storing necessary state. - * This now adheres to the SDK's void return type expectation for the interface. + * Generates and stores the authorization URL with state, without opening a popup. + * Used when preventAutoAuth is enabled to provide the URL for manual navigation. * @param authorizationUrl The fully constructed authorization URL from the SDK. + * @returns The full authorization URL with state parameter. */ - async redirectToAuthorization(authorizationUrl: URL): Promise { + async prepareAuthorizationUrl(authorizationUrl: URL): Promise { // Generate a unique state parameter for this authorization request const state = crypto.randomUUID() const stateKey = `${this.storageKeyPrefix}:state_${state}` @@ -151,6 +152,18 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { // Persist the exact auth URL in case the popup fails and manual navigation is needed localStorage.setItem(this.getKey('last_auth_url'), sanitizedAuthUrl) + return authUrlString + } + + /** + * Redirects the user agent to the authorization URL, storing necessary state. + * This now adheres to the SDK's void return type expectation for the interface. + * @param authorizationUrl The fully constructed authorization URL from the SDK. + */ + async redirectToAuthorization(authorizationUrl: URL): Promise { + // Prepare the authorization URL with state + const authUrlString = await this.prepareAuthorizationUrl(authorizationUrl) + // Attempt to open the popup const popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes' // Make configurable if needed try { diff --git a/src/react/types.ts b/src/react/types.ts index 5472b1f..9ac9aba 100644 --- a/src/react/types.ts +++ b/src/react/types.ts @@ -28,6 +28,8 @@ export type UseMcpOptions = { popupFeatures?: string /** Transport type preference: 'auto' (HTTP with SSE fallback), 'http' (HTTP only), 'sse' (SSE only) */ transportType?: 'auto' | 'http' | 'sse' + /** Prevent automatic authentication popup on initial connection (default: false) */ + preventAutoAuth?: boolean } export type UseMcpResult = { @@ -36,13 +38,14 @@ export type UseMcpResult = { /** * The current state of the MCP connection: * - 'discovering': Checking server existence and capabilities (including auth requirements). + * - 'pending_auth': Authentication is required but auto-popup was prevented. User action needed. * - 'authenticating': Authentication is required and the process (e.g., popup) has been initiated. * - 'connecting': Establishing the SSE connection to the server. * - 'loading': Connected; loading resources like the tool list. * - 'ready': Connected and ready for tool calls. * - 'failed': Connection or authentication failed. Check the `error` property. */ - state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed' + state: 'discovering' | 'pending_auth' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed' /** If the state is 'failed', this provides the error message */ error?: string /** diff --git a/src/react/useMcp.ts b/src/react/useMcp.ts index 3eec137..8645a82 100644 --- a/src/react/useMcp.ts +++ b/src/react/useMcp.ts @@ -34,6 +34,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { autoRetry = false, autoReconnect = DEFAULT_RECONNECT_DELAY, transportType = 'auto', + preventAutoAuth = false, } = options const [state, setState] = useState('discovering') @@ -326,9 +327,22 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { // Check for Auth error (Simplified - requires more thought for interaction with fallback) if (errorInstance instanceof UnauthorizedError || errorMessage.includes('Unauthorized') || errorMessage.includes('401')) { - addLog('info', 'Authentication required. Initiating SDK auth flow...') + addLog('info', 'Authentication required.') + + // Check if we have existing tokens before triggering auth flow + assert(authProviderRef.current, 'Auth Provider not available for auth flow') + const existingTokens = await authProviderRef.current.tokens() + + // If preventAutoAuth is enabled and no valid tokens exist, go to pending_auth state + if (preventAutoAuth && !existingTokens) { + addLog('info', 'Authentication required but auto-auth prevented. User action needed.') + setState('pending_auth') + // We'll set the auth URL when the user manually triggers auth + return 'auth_redirect' // Signal that we need user action + } + // Ensure state is set only once if multiple attempts trigger auth - if (stateRef.current !== 'authenticating') { + if (stateRef.current !== 'authenticating' && stateRef.current !== 'pending_auth') { setState('authenticating') if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) authTimeoutRef.current = setTimeout(() => { @@ -337,7 +351,6 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { } try { - assert(authProviderRef.current, 'Auth Provider not available for auth flow') const authResult = await auth(authProviderRef.current, { serverUrl: url }) if (!isMountedRef.current) return 'failed' // Unmounted during auth @@ -519,13 +532,44 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { }, [addLog, connect]) // Depends only on stable callbacks // authenticate is stable (depends on stable addLog, retry, connect) - const authenticate = useCallback(() => { + const authenticate = useCallback(async () => { addLog('info', 'Manual authentication requested...') const currentState = stateRef.current // Use ref if (currentState === 'failed') { addLog('info', 'Attempting to reconnect and authenticate via retry...') retry() + } else if (currentState === 'pending_auth') { + addLog('info', 'Proceeding with authentication from pending state...') + setState('authenticating') + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) + authTimeoutRef.current = setTimeout(() => { + /* ... timeout logic ... */ + }, AUTH_TIMEOUT) + + try { + assert(authProviderRef.current, 'Auth Provider not available for manual auth') + const authResult = await auth(authProviderRef.current, { serverUrl: url }) + + if (!isMountedRef.current) return + + if (authResult === 'AUTHORIZED') { + addLog('info', 'Manual authentication successful. Re-attempting connection...') + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) + connectingRef.current = false + connect() // Restart full connection sequence + } else if (authResult === 'REDIRECT') { + addLog('info', 'Redirecting for manual authentication. Waiting for callback...') + // State is already authenticating, wait for callback + } + } catch (authError) { + if (!isMountedRef.current) return + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) + failConnection( + `Manual authentication failed: ${authError instanceof Error ? authError.message : String(authError)}`, + authError instanceof Error ? authError : undefined, + ) + } } else if (currentState === 'authenticating') { addLog('warn', 'Already attempting authentication. Check for blocked popups or wait for timeout.') const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl() @@ -545,7 +589,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { // assert(authProviderRef.current, "Auth Provider not available"); // auth(authProviderRef.current, { serverUrl: url }).catch(failConnection); } - }, [addLog, retry, authUrl]) // Depends on stable callbacks and authUrl state + }, [addLog, retry, authUrl, url, failConnection, connect]) // Depends on stable callbacks and authUrl state // clearStorage is stable (depends on stable addLog, disconnect) const clearStorage = useCallback(() => { From 13a18d521945189b5fcad9f8ca754f0b07c7bcdb Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Wed, 2 Jul 2025 15:11:09 +1000 Subject: [PATCH 2/2] Fixing couple of things --- examples/chat-ui/package.json | 2 +- examples/inspector/package.json | 2 +- src/auth/browser-provider.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/chat-ui/package.json b/examples/chat-ui/package.json index b195ce0..bd3c94b 100644 --- a/examples/chat-ui/package.json +++ b/examples/chat-ui/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --port=5002", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", diff --git a/examples/inspector/package.json b/examples/inspector/package.json index b6f309d..8048a1e 100644 --- a/examples/inspector/package.json +++ b/examples/inspector/package.json @@ -4,7 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "vite --port=5001", "build": "tsc -b && pnpm run build:only", "build:only": "vite build", "lint": "eslint .", diff --git a/src/auth/browser-provider.ts b/src/auth/browser-provider.ts index 41c4af6..7b0e7b1 100644 --- a/src/auth/browser-provider.ts +++ b/src/auth/browser-provider.ts @@ -152,7 +152,7 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { // Persist the exact auth URL in case the popup fails and manual navigation is needed localStorage.setItem(this.getKey('last_auth_url'), sanitizedAuthUrl) - return authUrlString + return sanitizedAuthUrl } /** @@ -162,7 +162,7 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider { */ async redirectToAuthorization(authorizationUrl: URL): Promise { // Prepare the authorization URL with state - const authUrlString = await this.prepareAuthorizationUrl(authorizationUrl) + const sanitizedAuthUrl = await this.prepareAuthorizationUrl(authorizationUrl) // Attempt to open the popup const popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes' // Make configurable if needed