diff --git a/src/react/index.ts b/src/react/index.ts index d3408b2..a933982 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,177 +1,259 @@ -import { Tool, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; -import { useCallback, useEffect, useState, useRef } from "react"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; -import { discoverOAuthMetadata, startAuthorization, exchangeAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { Tool, OAuthMetadata, JSONRPCMessage, OAuthClientInformation, OAuthTokens } from '@modelcontextprotocol/sdk/types.js' +import { useCallback, useEffect, useState, useRef } from 'react' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { discoverOAuthMetadata, startAuthorization, exchangeAuthorization } from '@modelcontextprotocol/sdk/client/auth.js' export type UseMcpOptions = { /** The /sse URL of your remote MCP server */ - url: string, + url: string /** OAuth client name for registration */ - clientName?: string, + clientName?: string /** OAuth client URI for registration */ - clientUri?: string, + clientUri?: string /** Custom callback URL for OAuth redirect (defaults to /oauth/callback on the current origin) */ - callbackUrl?: string, + callbackUrl?: string /** Storage key prefix for OAuth data (defaults to "mcp_auth") */ - storageKeyPrefix?: string, + storageKeyPrefix?: string /** Custom configuration for the MCP client */ clientConfig?: { - name?: string, - version?: string, - }, + name?: string + version?: string + } /** Whether to enable debug logging */ - debug?: boolean, + debug?: boolean /** Auto retry connection if it fails, with delay in ms (default: false) */ - autoRetry?: boolean | number, + autoRetry?: boolean | number /** Auto reconnect if connection is lost, with delay in ms (default: 3000) */ - autoReconnect?: boolean | number, + autoReconnect?: boolean | number + /** OAuth authentication mode (default: 'popup-with-redirect-fallback') */ + authMode?: 'popup-only' | 'redirect-only' | 'popup-with-redirect-fallback' + /** Popup window features (dimensions and behavior) for OAuth */ + popupFeatures?: string } export type UseMcpResult = { - tools: Tool[], + tools: Tool[] /** * The current state of the MCP connection. This will be one of: * - 'discovering': Finding out whether there is in fact a server at that URL, and what its capabilities are * - 'authenticating': The server has indicated we must authenticate, so we can't proceed until that's complete + * - 'popup-blocked': The auth popup was blocked by the browser, manual authentication is needed * - 'connecting': The connection to the MCP server is being established. This happens before we know whether we need to authenticate or not, and then again once we have credentials * - 'loading': We're connected to the MCP server, and now we're loading its resources/prompts/tools * - 'ready': The MCP server is connected and ready to be used * - 'failed': The connection to the MCP server failed * */ - state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed', + state: 'discovering' | 'authenticating' | 'popup-blocked' | 'connecting' | 'loading' | 'ready' | 'failed' /** If the state is 'failed', this will be the error message */ - error?: string, + error?: string /** All internal log messages */ - log: {level: 'debug' | 'info' | 'warn' | 'error', message: string}[], + log: { level: 'debug' | 'info' | 'warn' | 'error'; message: string }[] /** Call a tool on the MCP server */ - callTool: (name: string, args?: Record) => Promise, + callTool: (name: string, args?: Record) => Promise /** Manually retry connection if it's in a failed state */ - retry: () => void, + retry: () => void /** Manually disconnect from the MCP server */ - disconnect: () => void, + disconnect: () => void + /** + * Manually trigger authentication (useful when popup is blocked) + * @returns Auth URL that can be used to manually open a new window + */ + authenticate: () => Promise + /** Authentication URL to manually open if popup is blocked */ + authUrl?: string } /** * Browser-compatible OAuth client provider for MCP */ class BrowserOAuthClientProvider { - private storageKeyPrefix: string; - private serverUrlHash: string; - private clientName: string; - private clientUri: string; - private callbackUrl: string; + private storageKeyPrefix: string + private serverUrlHash: string + private clientName: string + private clientUri: string + private callbackUrl: string constructor( readonly serverUrl: string, options: { - storageKeyPrefix?: string; - clientName?: string; - clientUri?: string; - callbackUrl?: string; - } = {} + storageKeyPrefix?: string + clientName?: string + clientUri?: string + callbackUrl?: string + } = {}, ) { - this.storageKeyPrefix = options.storageKeyPrefix || "mcp_auth"; - this.serverUrlHash = this.hashString(serverUrl); - this.clientName = options.clientName || "MCP Browser Client"; - this.clientUri = options.clientUri || window.location.origin; - this.callbackUrl = options.callbackUrl || new URL("/oauth/callback", window.location.origin).toString(); + this.storageKeyPrefix = options.storageKeyPrefix || 'mcp_auth' + this.serverUrlHash = this.hashString(serverUrl) + this.clientName = options.clientName || 'MCP Browser Client' + this.clientUri = options.clientUri || window.location.origin + this.callbackUrl = options.callbackUrl || new URL('/oauth/callback', window.location.origin).toString() } get redirectUrl(): string { - return this.callbackUrl; + return this.callbackUrl } get clientMetadata() { return { redirect_uris: [this.redirectUrl], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], client_name: this.clientName, client_uri: this.clientUri, - }; + } } private hashString(str: string): string { // Simple hash function for browser environments - let hash = 0; + let hash = 0 for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer } - return Math.abs(hash).toString(16); + return Math.abs(hash).toString(16) } private getKey(key: string): string { - return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}`; + return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}` } async clientInformation(): Promise { - const key = this.getKey("client_info"); - const data = localStorage.getItem(key); - if (!data) return undefined; + const key = this.getKey('client_info') + const data = localStorage.getItem(key) + if (!data) return undefined try { - return JSON.parse(data) as OAuthClientInformation; + return JSON.parse(data) as OAuthClientInformation } catch (e) { - return undefined; + return undefined } } async saveClientInformation(clientInformation: OAuthClientInformation): Promise { - const key = this.getKey("client_info"); - localStorage.setItem(key, JSON.stringify(clientInformation)); + const key = this.getKey('client_info') + localStorage.setItem(key, JSON.stringify(clientInformation)) } async tokens(): Promise { - const key = this.getKey("tokens"); - const data = localStorage.getItem(key); - if (!data) return undefined; + const key = this.getKey('tokens') + const data = localStorage.getItem(key) + if (!data) return undefined try { - return JSON.parse(data) as OAuthTokens; + return JSON.parse(data) as OAuthTokens } catch (e) { - return undefined; + return undefined } } async saveTokens(tokens: OAuthTokens): Promise { - const key = this.getKey("tokens"); - localStorage.setItem(key, JSON.stringify(tokens)); + const key = this.getKey('tokens') + localStorage.setItem(key, JSON.stringify(tokens)) } - async redirectToAuthorization(authorizationUrl: URL): Promise { + async redirectToAuthorization( + authorizationUrl: URL, + options?: { + mode?: 'popup-only' | 'redirect-only' | 'popup-with-redirect-fallback' + popupFeatures?: string + }, + ): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> { // Store the auth state for the popup flow - const stateKey = this.getKey("auth_state"); - const state = Math.random().toString(36).substring(2); - localStorage.setItem(stateKey, state); - authorizationUrl.searchParams.set("state", state); + const stateKey = this.getKey('auth_state') + const state = Math.random().toString(36).substring(2) + localStorage.setItem(stateKey, state) + authorizationUrl.searchParams.set('state', state) - // Open the authorization URL in a popup window - const popup = window.open(authorizationUrl.toString(), "mcp_auth", "width=600,height=700"); + const authUrl = authorizationUrl.toString() + const mode = options?.mode || 'popup-only' + const popupFeatures = options?.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes' - if (!popup || popup.closed) { - console.warn("Popup blocked. Redirecting in the same window..."); - window.location.href = authorizationUrl.toString(); + // Store the auth URL in case we need it for manual authentication + localStorage.setItem(this.getKey('auth_url'), authUrl) + console.log({ mode }) + + if (mode === 'redirect-only') { + // Redirect in the current window + window.location.href = authUrl + return { success: true, url: authUrl } } + + if (mode === 'popup-only' || mode === 'popup-with-redirect-fallback') { + try { + // Open the authorization URL in a popup window + const popup = window.open(authUrl, 'mcp_auth', popupFeatures) + console.log({ popup }) + + // Check if popup was blocked or closed immediately + if (!popup || popup.closed || popup.closed === undefined) { + if (mode === 'popup-with-redirect-fallback') { + // Fall back to redirect + console.warn('Popup blocked. Redirecting in the same window...') + window.location.href = authUrl + return { success: true, popupBlocked: true, url: authUrl } + } else { + // Popup-only mode, return error + console.warn('Popup blocked and redirect fallback disabled') + return { success: false, popupBlocked: true, url: authUrl } + } + } + + // Try to access the popup to confirm it's not blocked + try { + // Just accessing any property will throw if popup is blocked + const popupLocation = popup.location + // If we can read location.href, the popup is definitely working + if (popupLocation.href) { + // Successfully opened popup + return { success: true, url: authUrl } + } + } catch (e) { + // Access to the popup was denied, indicating it's blocked + if (mode === 'popup-with-redirect-fallback') { + console.warn('Popup blocked (security exception). Redirecting in the same window...') + window.location.href = authUrl + return { success: true, popupBlocked: true, url: authUrl } + } else { + console.warn('Popup blocked (security exception) and redirect fallback disabled') + return { success: false, popupBlocked: true, url: authUrl } + } + } + + // If we got here, popup is working + return { success: true, url: authUrl } + } catch (e) { + // Error opening popup + if (mode === 'popup-with-redirect-fallback') { + console.warn('Error opening popup:', e, 'Falling back to redirect') + window.location.href = authUrl + return { success: true, popupBlocked: true, url: authUrl } + } else { + console.warn('Error opening popup:', e, 'and redirect fallback disabled') + return { success: false, popupBlocked: true, url: authUrl } + } + } + } + + // This shouldn't happen given the enum constraint, but TypeScript doesn't know that + throw new Error(`Invalid auth mode: ${mode}`) } async saveCodeVerifier(codeVerifier: string): Promise { - const key = this.getKey("code_verifier"); - localStorage.setItem(key, codeVerifier); + const key = this.getKey('code_verifier') + localStorage.setItem(key, codeVerifier) } async codeVerifier(): Promise { - const key = this.getKey("code_verifier"); - const verifier = localStorage.getItem(key); + const key = this.getKey('code_verifier') + const verifier = localStorage.getItem(key) if (!verifier) { - throw new Error("No code verifier found in storage"); + throw new Error('No code verifier found in storage') } - return verifier; + return verifier } } @@ -181,96 +263,106 @@ class BrowserOAuthClientProvider { * and enables passing a list of tools (once loaded) to ai-sdk (using `useChat`). */ export function useMcp(options: UseMcpOptions): UseMcpResult { - const [state, setState] = useState('discovering'); - const [tools, setTools] = useState([]); - const [error, setError] = useState(undefined); - const [log, setLog] = useState([]); + const [state, setState] = useState('discovering') + const [tools, setTools] = useState([]) + const [error, setError] = useState(undefined) + const [log, setLog] = useState([]) + const [authUrl, setAuthUrl] = useState(undefined) - const clientRef = useRef(null); - const transportRef = useRef(null); - const authProviderRef = useRef(null); - const metadataRef = useRef(undefined); - const connectingRef = useRef(false); - const isInitialMount = useRef(true); + const clientRef = useRef(null) + const transportRef = useRef(null) + const authProviderRef = useRef(null) + const metadataRef = useRef(undefined) + const authUrlRef = useRef(undefined) + const codeVerifierRef = useRef(undefined) + const connectingRef = useRef(false) + const isInitialMount = useRef(true) + let handleAuthentication: () => Promise // Set up default options const { url, - clientName = "MCP React Client", + clientName = 'MCP React Client', clientUri = window.location.origin, - callbackUrl = new URL("/oauth/callback", window.location.origin).toString(), - storageKeyPrefix = "mcp_auth", + callbackUrl = new URL('/oauth/callback', window.location.origin).toString(), + storageKeyPrefix = 'mcp_auth', clientConfig = { - name: "mcp-react-client", - version: "0.1.0", + name: 'mcp-react-client', + version: '0.1.0', }, debug = false, autoRetry = false, autoReconnect = 3000, - } = options; + authMode = 'popup-only', + popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes', + } = options // Add to log - const addLog = useCallback((level: 'debug' | 'info' | 'warn' | 'error', message: string) => { - if (level === 'debug' && !debug) return; - setLog(prevLog => [...prevLog, { level, message }]); - }, [debug]); + const addLog = useCallback( + (level: 'debug' | 'info' | 'warn' | 'error', message: string) => { + console.log(message) + if (level === 'debug' && !debug) return + setLog((prevLog) => [...prevLog, { level, message }]) + }, + [debug], + ) // Call a tool on the MCP server - const callTool = useCallback(async (name: string, args?: Record) => { - if (!clientRef.current || state !== 'ready') { - throw new Error("MCP client not ready"); - } + const callTool = useCallback( + async (name: string, args?: Record) => { + if (!clientRef.current || state !== 'ready') { + throw new Error('MCP client not ready') + } - try { - const result = await clientRef.current.request( - { - method: "tools/call", + try { + const result = await clientRef.current.request({ + method: 'tools/call', params: { name, arguments: args }, - } - ); - return result; - } catch (err) { - addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`); - throw err; - } - }, [state, addLog]); + }) + return result + } catch (err) { + addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`) + throw err + } + }, + [state, addLog], + ) // Disconnect from the MCP server const disconnect = useCallback(async () => { if (clientRef.current) { try { - await clientRef.current.close(); + await clientRef.current.close() } catch (err) { - addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`); + addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`) } - clientRef.current = null; + clientRef.current = null } if (transportRef.current) { try { - await transportRef.current.close(); + await transportRef.current.close() } catch (err) { - addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`); + addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`) } - transportRef.current = null; + transportRef.current = null } - connectingRef.current = false; - setState('discovering'); - setTools([]); - setError(undefined); - }, [addLog]); - let handleAuthentication: () => Promise; + connectingRef.current = false + setState('discovering') + setTools([]) + setError(undefined) + }, [addLog]) // Initialize connection to MCP server const connect = useCallback(async () => { // Prevent multiple simultaneous connection attempts - if (connectingRef.current) return; - connectingRef.current = true; + if (connectingRef.current) return + connectingRef.current = true try { - setState('discovering'); - setError(undefined); + setState('discovering') + setError(undefined) // Create auth provider if not already created if (!authProviderRef.current) { @@ -279,282 +371,365 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { clientName, clientUri, callbackUrl, - }); + }) } // Discover OAuth metadata if not already discovered if (!metadataRef.current) { - addLog('info', 'Discovering OAuth metadata...'); - metadataRef.current = await discoverOAuthMetadata(url); - addLog('debug', `OAuth metadata: ${metadataRef.current ? 'Found' : 'Not available'}`); + addLog('info', 'Discovering OAuth metadata...') + metadataRef.current = await discoverOAuthMetadata(url) + addLog('debug', `OAuth metadata: ${metadataRef.current ? 'Found' : 'Not available'}`) } // Create MCP client clientRef.current = new Client( { - name: clientConfig.name || "mcp-react-client", - version: clientConfig.version || "0.1.0", + name: clientConfig.name || 'mcp-react-client', + version: clientConfig.version || '0.1.0', }, { capabilities: { sampling: {}, }, - } - ); + }, + ) // Set up auth flow - check if we have tokens - const tokens = await authProviderRef.current.tokens(); + const tokens = await authProviderRef.current.tokens() // Create SSE transport - setState('connecting'); - addLog('info', 'Creating transport...'); + setState('connecting') + addLog('info', 'Creating transport...') - const serverUrl = new URL(url); + const serverUrl = new URL(url) transportRef.current = new SSEClientTransport(serverUrl, { - authProvider: authProviderRef.current - }); + authProvider: authProviderRef.current, + }) // Set up transport handlers transportRef.current.onmessage = (message: JSONRPCMessage) => { - addLog('debug', `Received message: ${message.method || message.id}`); - }; + addLog('debug', `Received message: ${message.method || message.id}`) + } transportRef.current.onerror = (err: Error) => { - addLog('error', `Transport error: ${err.message}`); + addLog('error', `Transport error: ${err.message}`) if (err.message.includes('Unauthorized')) { - setState('authenticating'); - handleAuthentication().catch(authErr => { - addLog('error', `Authentication error: ${authErr.message}`); - setState('failed'); - setError(`Authentication failed: ${authErr.message}`); - connectingRef.current = false; - }); + setState('authenticating') + handleAuthentication().catch((authErr) => { + addLog('error', `Authentication error: ${authErr.message}`) + setState('failed') + setError(`Authentication failed: ${authErr.message}`) + connectingRef.current = false + }) } else { - setState('failed'); - setError(`Connection error: ${err.message}`); - connectingRef.current = false; + setState('failed') + setError(`Connection error: ${err.message}`) + connectingRef.current = false } - }; + } transportRef.current.onclose = () => { - addLog('info', 'Connection closed'); + addLog('info', 'Connection closed') // If we were previously connected, try to reconnect if (state === 'ready' && autoReconnect) { - const delay = typeof autoReconnect === 'number' ? autoReconnect : 3000; - addLog('info', `Will reconnect in ${delay}ms...`); + const delay = typeof autoReconnect === 'number' ? autoReconnect : 3000 + addLog('info', `Will reconnect in ${delay}ms...`) setTimeout(() => { - disconnect().then(() => connect()); - }, delay); + disconnect().then(() => connect()) + }, delay) } - }; + } // Connect transport try { - addLog('info', 'Starting transport...'); - await transportRef.current.start(); + addLog('info', 'Starting transport...') + await transportRef.current.start() } catch (err) { - addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`); + addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`) if (err instanceof Error && err.message.includes('Unauthorized')) { - setState('authenticating'); + setState('authenticating') // Start authentication process - await handleAuthentication(); + await handleAuthentication() // After successful auth, retry connection - return connect(); + return connect() } else { - setState('failed'); - setError(`Connection error: ${err instanceof Error ? err.message : String(err)}`); - connectingRef.current = false; - return; + setState('failed') + setError(`Connection error: ${err instanceof Error ? err.message : String(err)}`) + connectingRef.current = false + return } } // Connect client try { - addLog('info', 'Connecting client...'); - setState('loading'); - await clientRef.current.connect(transportRef.current); - addLog('info', 'Client connected'); + addLog('info', 'Connecting client...') + setState('loading') + await clientRef.current.connect(transportRef.current) + addLog('info', 'Client connected') // Load tools try { - addLog('info', 'Loading tools...'); - const toolsResponse = await clientRef.current.request( - { method: "tools/list" }, - ListToolsResultSchema - ); - setTools(toolsResponse.tools); - addLog('info', `Loaded ${toolsResponse.tools.length} tools`); + addLog('info', 'Loading tools...') + const toolsResponse = await clientRef.current.request({ method: 'tools/list' }, ListToolsResultSchema) + setTools(toolsResponse.tools) + addLog('info', `Loaded ${toolsResponse.tools.length} tools`) // Connection completed successfully - setState('ready'); - connectingRef.current = false; + setState('ready') + connectingRef.current = false } catch (toolErr) { - addLog('error', `Error loading tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`); + addLog('error', `Error loading tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`) // We're still connected, just couldn't load tools - setState('ready'); - connectingRef.current = false; + setState('ready') + connectingRef.current = false } } catch (connectErr) { - addLog('error', `Client connect error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`); - setState('failed'); - setError(`Connection error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`); - connectingRef.current = false; + addLog('error', `Client connect error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`) + setState('failed') + setError(`Connection error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`) + connectingRef.current = false } } catch (err) { - addLog('error', `Unexpected error: ${err instanceof Error ? err.message : String(err)}`); - setState('failed'); - setError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`); - connectingRef.current = false; + addLog('error', `Unexpected error: ${err instanceof Error ? err.message : String(err)}`) + setState('failed') + setError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`) + connectingRef.current = false } - }, [url, clientName, clientUri, callbackUrl, storageKeyPrefix, clientConfig, debug, autoReconnect, addLog, handleAuthentication, disconnect]); + }, [ + url, + clientName, + clientUri, + callbackUrl, + storageKeyPrefix, + clientConfig, + debug, + autoReconnect, + addLog, + handleAuthentication, + disconnect, + ]) - // Handle authentication flow - handleAuthentication = useCallback(async () => { + // Provide public authenticate method + const authenticate = useCallback(async (): Promise => { + if (!authUrlRef.current) { + await startAuthFlow() + } + + if (authUrlRef.current) { + return authUrlRef.current.toString() + } + return undefined + }, []) + + // Start the auth flow and get the auth URL + const startAuthFlow = useCallback(async (): Promise => { if (!authProviderRef.current || !metadataRef.current) { - throw new Error("Auth provider or metadata not available"); + throw new Error('Auth provider or metadata not available') } - addLog('info', 'Starting authentication flow...'); + addLog('info', 'Starting authentication flow...') // Check if we have client info - let clientInfo = await authProviderRef.current.clientInformation(); + let clientInfo = await authProviderRef.current.clientInformation() if (!clientInfo) { // Register client dynamically - addLog('info', 'No client information found, registering...'); + addLog('info', 'No client information found, registering...') // Note: In a complete implementation, you'd register the client here // This would be done server-side in a real application - throw new Error("Dynamic client registration not implemented in this example"); + throw new Error('Dynamic client registration not implemented in this example') } // Start authorization flow - addLog('info', 'Starting authorization...'); - const {authorizationUrl, codeVerifier} = await startAuthorization(url, { + addLog('info', 'Preparing authorization...') + const { authorizationUrl, codeVerifier } = await startAuthorization(url, { metadata: metadataRef.current, clientInformation: clientInfo, - redirectUrl: authProviderRef.current.redirectUrl - }); + redirectUrl: authProviderRef.current.redirectUrl, + }) - // Save code verifier - await authProviderRef.current.saveCodeVerifier(codeVerifier); + // Save code verifier and auth URL for later use + await authProviderRef.current.saveCodeVerifier(codeVerifier) + codeVerifierRef.current = codeVerifier + authUrlRef.current = authorizationUrl + setAuthUrl(authorizationUrl.toString()) + + return authorizationUrl + }, [url, addLog]) + + // Handle authentication flow + handleAuthentication = useCallback(async () => { + if (!authProviderRef.current) { + throw new Error('Auth provider not available') + } + + // Get or create the auth URL + if (!authUrlRef.current) { + try { + await startAuthFlow() + } catch (err) { + addLog('error', `Failed to start auth flow: ${err instanceof Error ? err.message : String(err)}`) + throw err + } + } + + if (!authUrlRef.current) { + throw new Error('Failed to create authorization URL') + } // Set up listener for post-auth message const authPromise = new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - window.removeEventListener('message', messageHandler); - reject(new Error("Authentication timeout after 5 minutes")); - }, 5 * 60 * 1000); + const timeoutId = setTimeout( + () => { + window.removeEventListener('message', messageHandler) + reject(new Error('Authentication timeout after 5 minutes')) + }, + 5 * 60 * 1000, + ) const messageHandler = (event: MessageEvent) => { // Verify origin for security - if (event.origin !== window.location.origin) return; + if (event.origin !== window.location.origin) return if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { - window.removeEventListener('message', messageHandler); - clearTimeout(timeoutId); - resolve(event.data.code); + window.removeEventListener('message', messageHandler) + clearTimeout(timeoutId) + resolve(event.data.code) } - }; + } - window.addEventListener('message', messageHandler); - }); + window.addEventListener('message', messageHandler) + }) // Redirect to authorization - await authProviderRef.current.redirectToAuthorization(authorizationUrl); + addLog('info', 'Opening authorization window...') + const redirectResult = await authProviderRef.current.redirectToAuthorization(authUrlRef.current, { + mode: authMode, + popupFeatures, + }) + + if (!redirectResult.success) { + // Popup was blocked and we're in popup-only mode + setState('popup-blocked') + setError('Authentication popup was blocked by the browser') + addLog('warn', 'Authentication popup was blocked. User needs to manually authorize.') + throw new Error('Authentication popup blocked') + } + + if (redirectResult.popupBlocked && authMode === 'popup-with-redirect-fallback') { + // The popup was blocked but we've fallen back to redirect + // No need to wait for the auth promise since we're redirecting + addLog('info', 'Popup blocked, falling back to redirect...') + return 'redirect-in-progress' + } // Wait for auth to complete - addLog('info', 'Waiting for authorization...'); - const code = await authPromise; - addLog('info', 'Authorization code received'); + addLog('info', 'Waiting for authorization...') + const code = await authPromise + addLog('info', 'Authorization code received') - return code; - }, [url, addLog]); + return code + }, [url, addLog, authMode, popupFeatures, startAuthFlow]) // Handle auth completion - this is called when we receive a message from the popup - const handleAuthCompletion = useCallback(async (code: string) => { - if (!authProviderRef.current || !transportRef.current || !metadataRef.current) { - throw new Error("Authentication context not available"); - } + const handleAuthCompletion = useCallback( + async (code: string) => { + if (!authProviderRef.current || !transportRef.current) { + throw new Error('Authentication context not available') + } - try { - addLog('info', 'Finishing authorization...'); - await transportRef.current.finishAuth(code); - addLog('info', 'Authorization completed'); + try { + addLog('info', 'Finishing authorization...') + await transportRef.current.finishAuth(code) + addLog('info', 'Authorization completed') - // Reconnect with the new auth token - await disconnect(); - connect(); - } catch (err) { - addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`); - setState('failed'); - setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, [addLog, disconnect, connect]); + // Reset auth URL state + authUrlRef.current = undefined + setAuthUrl(undefined) + + // Reset popup blocked state if we were in that state + if (state === 'popup-blocked') { + setState('authenticating') + } + + // Reconnect with the new auth token + await disconnect() + connect() + } catch (err) { + addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`) + setState('failed') + setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } + }, + [addLog, disconnect, connect, state], + ) // Retry connection const retry = useCallback(() => { if (state === 'failed') { - disconnect().then(() => connect()); + disconnect().then(() => connect()) } - }, [state, disconnect, connect]); + }, [state, disconnect, connect]) // Set up message listener for auth callback useEffect(() => { const messageHandler = (event: MessageEvent) => { // Verify origin for security - if (event.origin !== window.location.origin) return; + if (event.origin !== window.location.origin) return if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { - handleAuthCompletion(event.data.code).catch(err => { - addLog('error', `Auth callback error: ${err.message}`); - }); + handleAuthCompletion(event.data.code).catch((err) => { + addLog('error', `Auth callback error: ${err.message}`) + }) } - }; + } - window.addEventListener('message', messageHandler); + window.addEventListener('message', messageHandler) return () => { - window.removeEventListener('message', messageHandler); - }; - }, [handleAuthCompletion, addLog]); + window.removeEventListener('message', messageHandler) + } + }, [handleAuthCompletion, addLog]) // Initial connection and auto-retry useEffect(() => { if (isInitialMount.current) { - isInitialMount.current = false; - connect(); + isInitialMount.current = false + connect() } else if (state === 'failed' && autoRetry) { - const delay = typeof autoRetry === 'number' ? autoRetry : 5000; + const delay = typeof autoRetry === 'number' ? autoRetry : 5000 const timeoutId = setTimeout(() => { - addLog('info', 'Auto-retrying connection...'); - disconnect().then(() => connect()); - }, delay); + addLog('info', 'Auto-retrying connection...') + disconnect().then(() => connect()) + }, delay) return () => { - clearTimeout(timeoutId); - }; + clearTimeout(timeoutId) + } } - }, [state, autoRetry, connect, disconnect, addLog]); + }, [state, autoRetry, connect, disconnect, addLog]) // Clean up on unmount useEffect(() => { return () => { if (clientRef.current || transportRef.current) { - disconnect(); + disconnect() } - }; - }, [disconnect]); + } + }, [disconnect]) return { state, tools, error, log, + authUrl, callTool, retry, - disconnect - }; + disconnect, + authenticate, + } } /** @@ -569,102 +744,103 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { export async function onMcpAuthorization(query: Record) { try { // Extract the authorization code and state - const code = query.code; - const state = query.state; + const code = query.code + const state = query.state if (!code) { - throw new Error("No authorization code received"); + throw new Error('No authorization code received') } if (!state) { - throw new Error("No state parameter received"); + throw new Error('No state parameter received') } // Find the matching auth state in localStorage - const storageKeys = Object.keys(localStorage).filter(key => - key.includes('_auth_state') && localStorage.getItem(key) === state - ); + const storageKeys = Object.keys(localStorage).filter((key) => key.includes('_auth_state') && localStorage.getItem(key) === state) if (storageKeys.length === 0) { - throw new Error("No matching auth state found in storage"); + throw new Error('No matching auth state found in storage') } - const storageKey = storageKeys[0]; - const keyParts = storageKey.split('_'); - const serverUrlHash = keyParts[1]; - const storageKeyPrefix = keyParts[0]; + const storageKey = storageKeys[0] + const keyParts = storageKey.split('_') + const serverUrlHash = keyParts[1] + const storageKeyPrefix = keyParts[0] // Find all related auth data with the same prefix and server hash - const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info`; - const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier`; + const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info` + const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier` - const clientInfoStr = localStorage.getItem(clientInfoKey); - const codeVerifier = localStorage.getItem(codeVerifierKey); + const clientInfoStr = localStorage.getItem(clientInfoKey) + const codeVerifier = localStorage.getItem(codeVerifierKey) if (!clientInfoStr) { - throw new Error("No client information found in storage"); + throw new Error('No client information found in storage') } if (!codeVerifier) { - throw new Error("No code verifier found in storage"); + throw new Error('No code verifier found in storage') } // Parse client info - const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation; + const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation // Find the server URL from other keys in localStorage - const serverUrlKeys = Object.keys(localStorage).filter(key => - key.startsWith(`${storageKeyPrefix}_server_`) && key.includes(serverUrlHash) - ); + const serverUrlKeys = Object.keys(localStorage).filter( + (key) => key.startsWith(`${storageKeyPrefix}_server_`) && key.includes(serverUrlHash), + ) - let serverUrl: string; + let serverUrl: string if (serverUrlKeys.length > 0) { - serverUrl = localStorage.getItem(serverUrlKeys[0]) || ''; + serverUrl = localStorage.getItem(serverUrlKeys[0]) || '' } else { // If we can't find the server URL, try to construct it from the current URL // This is a fallback and may not always work - const currentUrl = new URL(window.location.href); - serverUrl = `${currentUrl.protocol}//${currentUrl.host}`; + const currentUrl = new URL(window.location.href) + serverUrl = `${currentUrl.protocol}//${currentUrl.host}` } if (!serverUrl) { - throw new Error("Could not determine server URL"); + throw new Error('Could not determine server URL') } // Exchange the code for tokens - const metadata = await discoverOAuthMetadata(serverUrl); + const metadata = await discoverOAuthMetadata(serverUrl) const tokens = await exchangeAuthorization(serverUrl, { metadata, clientInformation: clientInfo, authorizationCode: code, codeVerifier, - }); + }) // Save the tokens - const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens`; - localStorage.setItem(tokensKey, JSON.stringify(tokens)); + const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens` + localStorage.setItem(tokensKey, JSON.stringify(tokens)) // Post message back to the parent window if (window.opener && !window.opener.closed) { - window.opener.postMessage({ - type: 'mcp_auth_callback', - code - }, window.location.origin); + window.opener.postMessage( + { + type: 'mcp_auth_callback', + code, + }, + window.location.origin, + ) // Close the popup - window.close(); + window.close() } else { // If no parent window, we're in a redirect flow // Redirect back to the main page - window.location.href = '/'; + window.location.href = '/' } - return { success: true }; + return { success: true } } catch (error) { - console.error('Error in MCP authorization:', error); + console.error('Error in MCP authorization:', error) // Create a readable error message for display - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error) // If the popup is still open, show the error const errorHtml = ` @@ -684,10 +860,10 @@ export async function onMcpAuthorization(query: Record) {

You can close this window and try again.

- `; + ` - document.body.innerHTML = errorHtml; + document.body.innerHTML = errorHtml - return { success: false, error: errorMessage }; + return { success: false, error: errorMessage } } -} \ No newline at end of file +}