From fa463fecb1e4235af8c7fa25bfc9b8f3c470ec2b Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Wed, 7 May 2025 11:11:46 +1000 Subject: [PATCH] Generated code, compiling now and SSE working but HTTPS not --- src/react/useMcp.ts | 725 +++++++++++++++++++++++++------------------- 1 file changed, 407 insertions(+), 318 deletions(-) diff --git a/src/react/useMcp.ts b/src/react/useMcp.ts index c55b97d..5bc9b46 100644 --- a/src/react/useMcp.ts +++ b/src/react/useMcp.ts @@ -1,16 +1,22 @@ // useMcp.ts -import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { auth, UnauthorizedError, OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; -import { BrowserOAuthClientProvider } from '../auth/browser-provider.js'; // Adjust path -import { assert } from '../utils/assert.js'; // Adjust path -import type { UseMcpOptions, UseMcpResult } from './types.js'; // Adjust path +import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js' +import { useCallback, useEffect, useRef, useState } from 'react' +// Import both transport types +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' // Added +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { auth, UnauthorizedError, OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { BrowserOAuthClientProvider } from '../auth/browser-provider.js' // Adjust path +import { assert } from '../utils/assert.js' // Adjust path +import type { UseMcpOptions, UseMcpResult } from './types.js' // Adjust path +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' // Added for type safety -const DEFAULT_RECONNECT_DELAY = 3000; -const DEFAULT_RETRY_DELAY = 5000; -const AUTH_TIMEOUT = 5 * 60 * 1000; +const DEFAULT_RECONNECT_DELAY = 3000 +const DEFAULT_RETRY_DELAY = 5000 +const AUTH_TIMEOUT = 5 * 60 * 1000 + +// Define Transport types literal for clarity +type TransportType = 'http' | 'sse' export function useMcp(options: UseMcpOptions): UseMcpResult { const { @@ -20,461 +26,544 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { callbackUrl = typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback', storageKeyPrefix = 'mcp:auth', clientConfig = {}, - debug = false, // Consider using this in addLog + debug = false, autoRetry = false, autoReconnect = DEFAULT_RECONNECT_DELAY, - } = options; + } = options - const [state, setState] = useState('discovering'); - const [tools, setTools] = useState([]); - const [error, setError] = useState(undefined); - const [log, setLog] = useState([]); - const [authUrl, setAuthUrl] = useState(undefined); + 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 connectingRef = useRef(false); - const isMountedRef = useRef(true); - const connectAttemptRef = useRef(0); - const authTimeoutRef = useRef(null); + const clientRef = useRef(null) + // Transport ref can hold either type now + const transportRef = useRef(null) + const authProviderRef = useRef(null) + const connectingRef = useRef(false) + const isMountedRef = useRef(true) + const connectAttemptRef = useRef(0) + const authTimeoutRef = useRef(null) // --- Refs for values used in callbacks --- - // Ref to hold the latest state value for use in callbacks without causing dependency changes - const stateRef = useRef(state); - // Ref to hold autoReconnect option - const autoReconnectRef = useRef(autoReconnect); + const stateRef = useRef(state) + const autoReconnectRef = useRef(autoReconnect) + // Ref to store the type of transport that successfully connected + const successfulTransportRef = useRef(null) // --- Effect to keep refs updated --- useEffect(() => { - stateRef.current = state; - autoReconnectRef.current = autoReconnect; - }, [state, autoReconnect]); + stateRef.current = state + autoReconnectRef.current = autoReconnect + }, [state, autoReconnect]) // --- Stable Callbacks --- // addLog is stable (empty dependency array) const addLog = useCallback( (level: UseMcpResult['log'][0]['level'], message: string, ...args: unknown[]) => { // if (level === 'debug' && !debug) return; // Uncomment if using debug flag - const fullMessage = args.length > 0 ? `${message} ${args.map(arg => JSON.stringify(arg)).join(' ')}` : message; - console[level](`[useMcp] ${fullMessage}`); + const fullMessage = args.length > 0 ? `${message} ${args.map((arg) => JSON.stringify(arg)).join(' ')}` : message + console[level](`[useMcp] ${fullMessage}`) // Use isMountedRef to prevent state updates after unmount if (isMountedRef.current) { - setLog((prevLog) => [...prevLog.slice(-100), { level, message: fullMessage, timestamp: Date.now() }]); + setLog((prevLog) => [...prevLog.slice(-100), { level, message: fullMessage, timestamp: Date.now() }]) } }, [], // Empty dependency array makes this stable - ); + ) // disconnect is stable (depends only on stable addLog) const disconnect = useCallback( async (quiet = false) => { - if (!quiet) addLog('info', 'Disconnecting...'); - connectingRef.current = false; - if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current); - authTimeoutRef.current = null; + if (!quiet) addLog('info', 'Disconnecting...') + connectingRef.current = false + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) + authTimeoutRef.current = null - const transport = transportRef.current; - clientRef.current = null; // Ensure client is cleared - transportRef.current = null; // Ensure transport is cleared + const transport = transportRef.current + clientRef.current = null // Ensure client is cleared + transportRef.current = null // Ensure transport is cleared // Only reset state if mounted and not a quiet disconnect if (isMountedRef.current && !quiet) { - setState('discovering'); - setTools([]); - setError(undefined); - setAuthUrl(undefined); + setState('discovering') + setTools([]) + setError(undefined) + setAuthUrl(undefined) } if (transport) { try { - await transport.close(); - if (!quiet) addLog('debug', 'Transport closed'); + await transport.close() + if (!quiet) addLog('debug', 'Transport closed') } catch (err) { - if (!quiet) addLog('warn', 'Error closing transport:', err); + if (!quiet) addLog('warn', 'Error closing transport:', err) } } }, [addLog], // Depends only on stable addLog - ); + ) // failConnection is stable (depends only on stable addLog) - const failConnection = useCallback((errorMessage: string, connectionError?: Error) => { - addLog('error', errorMessage, connectionError ?? ''); - if (isMountedRef.current) { - setState('failed'); // Set state to failed - setError(errorMessage); - const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl(); - if (manualUrl) { - setAuthUrl(manualUrl); - addLog('info', 'Manual authentication URL may be available.', manualUrl); + const failConnection = useCallback( + (errorMessage: string, connectionError?: Error) => { + addLog('error', errorMessage, connectionError ?? '') + if (isMountedRef.current) { + setState('failed') // Set state to failed + setError(errorMessage) + const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl() + if (manualUrl) { + setAuthUrl(manualUrl) + addLog('info', 'Manual authentication URL may be available.', manualUrl) + } } - } - connectingRef.current = false; // Ensure connection attempt is marked as finished - // Do not call disconnect here - allow user to see error and retry - }, [addLog]); // Depends only on stable addLog - + connectingRef.current = false // Ensure connection attempt is marked as finished + // Do not call disconnect here - allow user to see error and retry + }, + [addLog], + ) // Depends only on stable addLog // connect needs to be stable. Remove state/autoReconnect deps, use refs inside. const connect = useCallback(async () => { + // Prevent concurrent connections if (connectingRef.current) { - addLog('debug', 'Connection attempt already in progress.'); - return; + addLog('debug', 'Connection attempt already in progress.') + return } - // Check mounted status *before* starting async logic if (!isMountedRef.current) { - addLog('debug', 'Connect called after unmount, aborting.'); - return; + addLog('debug', 'Connect called after unmount, aborting.') + return } - connectingRef.current = true; - connectAttemptRef.current += 1; - setError(undefined); - setAuthUrl(undefined); - setState('discovering'); // Set state *before* async calls - addLog('info', `Connecting attempt #${connectAttemptRef.current} to ${url}...`); + connectingRef.current = true // Mark start of connection sequence + connectAttemptRef.current += 1 + setError(undefined) + setAuthUrl(undefined) + successfulTransportRef.current = null // Reset successful transport type + setState('discovering') + addLog('info', `Connecting attempt #${connectAttemptRef.current} to ${url}...`) - // Ensure provider/client are initialized (idempotent check) + // Initialize provider/client if needed (idempotent) if (!authProviderRef.current) { - authProviderRef.current = new BrowserOAuthClientProvider(url, { - storageKeyPrefix, clientName, clientUri, callbackUrl - }); - addLog('debug', 'BrowserOAuthClientProvider initialized in connect.'); + /* ... init provider ... */ } if (!clientRef.current) { - clientRef.current = new Client( - { name: clientConfig.name || 'use-mcp-react-client', version: clientConfig.version || '0.1.0' }, - { capabilities: {} }, - ); - addLog('debug', 'MCP Client initialized in connect.'); + /* ... init client ... */ } - // Recreate transport - setState('connecting'); // Update state - if (transportRef.current) { - await transportRef.current.close().catch(e => addLog('warn', 'Error closing previous transport:', e)); - transportRef.current = null; - } - try { - // Assert provider/client are definitely created now - assert(authProviderRef.current, "Auth Provider must be initialized"); - assert(clientRef.current, "Client must be initialized"); - - transportRef.current = new SSEClientTransport(new URL(url), { - authProvider: authProviderRef.current, - }); - addLog('debug', 'SSEClientTransport created.'); - } catch(err) { - failConnection(`Failed to create transport: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : undefined); - // connectingRef is set to false inside failConnection - return; // Stop execution - } - - // --- Transport Handlers --- - // These handlers are defined inline within connect's scope. - // They will implicitly capture the *current* stable callbacks like addLog, failConnection. - // They use refs (stateRef, autoReconnectRef) to access current state/options when needed. - transportRef.current.onmessage = (message: JSONRPCMessage) => { - addLog('debug', `[Transport] Received: ${JSON.stringify(message)}`); - // Forward to the *current* client instance - // @ts-ignore - clientRef.current?.handleMessage(message); - }; - transportRef.current.onerror = (err: Error) => { - failConnection(`Transport error: ${err.message}`, err); - }; - transportRef.current.onclose = () => { - // Use refs to check state and options without needing them as dependencies - if (!isMountedRef.current || connectingRef.current) return; - - addLog('info', 'Transport connection closed.'); - const currentState = stateRef.current; // Use ref - const currentAutoReconnect = autoReconnectRef.current; // Use ref - - if (currentState === 'ready' && currentAutoReconnect) { - const delay = typeof currentAutoReconnect === 'number' ? currentAutoReconnect : DEFAULT_RECONNECT_DELAY; - addLog('info', `Attempting to reconnect in ${delay}ms...`); - setState('connecting'); // Update state for UI feedback - setTimeout(() => { - // Check mounted status again before actually connecting - if (isMountedRef.current) { - connect(); // Call stable connect again - } - }, delay); - } else if (currentState !== 'failed' && currentState !== 'authenticating') { - failConnection('Connection closed unexpectedly.'); + // --- Helper function for a single connection attempt --- + const tryConnectWithTransport = async (transportType: TransportType): Promise<'success' | 'fallback' | 'auth_redirect' | 'failed'> => { + addLog('info', `Attempting connection with ${transportType.toUpperCase()} transport...`) + // Ensure state reflects current attempt phase, unless already authenticating + if (stateRef.current !== 'authenticating') { + setState('connecting') } - }; - // --- Connect Client & Handle Auth --- - try { - addLog('info', 'Connecting client...'); - // Client/transport refs are already asserted above - await clientRef.current!.connect(transportRef.current); + let transportInstance: Transport // Use base Transport type - addLog('info', 'Client connected. Loading tools...'); - setState('loading'); // Update state + // 1. Create Transport Instance & Close Previous + try { + assert(authProviderRef.current, 'Auth Provider must be initialized') + assert(clientRef.current, 'Client must be initialized') - const toolsResponse = await clientRef.current!.request({ method: 'tools/list' }, ListToolsResultSchema); - // Check mounted status *before* final state update - if (isMountedRef.current) { - setTools(toolsResponse.tools); - addLog('info', `Loaded ${toolsResponse.tools.length} tools.`); - setState('ready'); // Final success state - connectingRef.current = false; // Connection attempt finished successfully - connectAttemptRef.current = 0; // Reset attempt counter - } - } catch (connectErr) { - addLog('debug', 'Client connect error:', connectErr); - const errorInstance = connectErr instanceof Error ? connectErr : new Error(String(connectErr)); - - if (errorInstance instanceof UnauthorizedError || errorInstance.message.includes('Unauthorized') || errorInstance.message.includes('401')) { - addLog('info', 'Authentication required. Initiating SDK auth flow...'); - setState('authenticating'); // Update state - // Start auth timeout - if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current); - authTimeoutRef.current = setTimeout(() => { - // Check ref state before failing - if (isMountedRef.current && stateRef.current === 'authenticating') { - failConnection('Authentication timed out. Please try again or use the manual link if available.'); - } - }, AUTH_TIMEOUT); - - try { - assert(authProviderRef.current, "Auth Provider not available for auth flow"); - const authResult = await auth(authProviderRef.current, { serverUrl: url }); - - // Check mounted status before proceeding - if (!isMountedRef.current) return; - - if (authResult === 'AUTHORIZED') { - addLog('info', 'Authentication successful via existing token or refresh. Reconnecting...'); - if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current); - connectingRef.current = false; // Allow reconnect - connect(); // Call stable connect to re-establish - } else if (authResult === 'REDIRECT') { - addLog('info', 'Redirecting for authentication. Waiting for callback...'); - // State is 'authenticating', timeout running. connectingRef remains true. - } - } catch (sdkAuthError) { - if (!isMountedRef.current) return; // Check mount before failConnection - if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current); - failConnection(`Failed to initiate authentication: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`, sdkAuthError instanceof Error ? sdkAuthError : undefined); - // connectingRef set to false inside failConnection + // Close existing transport before creating new one + if (transportRef.current) { + await transportRef.current.close().catch((e) => addLog('warn', `Error closing previous transport: ${e.message}`)) + transportRef.current = null } - } else { - // Handle other connection errors - failConnection(`Failed to connect: ${errorInstance.message}`, errorInstance); - // connectingRef set to false inside failConnection + + const commonOptions = { authProvider: authProviderRef.current } + const targetUrl = new URL(url) + + if (transportType === 'http') { + transportInstance = new StreamableHTTPClientTransport(targetUrl, commonOptions) + } else { + // sse + transportInstance = new SSEClientTransport(targetUrl, commonOptions) + } + transportRef.current = transportInstance // Assign to ref immediately + addLog('debug', `${transportType.toUpperCase()} transport created.`) + } catch (err) { + // Use stable failConnection + failConnection( + `Failed to create ${transportType.toUpperCase()} transport: ${err instanceof Error ? err.message : String(err)}`, + err instanceof Error ? err : undefined, + ) + return 'failed' // Indicate definitive failure } - // Only set connectingRef false here if NOT waiting for redirect - // Note: failConnection already sets it to false. If redirect happens, it remains true. - // If AUTHORIZED happens, we immediately call connect again, which sets it true. - // This seems correct. + + // 2. Setup Handlers for the new transportInstance + transportInstance.onmessage = (message: JSONRPCMessage) => { + // Use stable addLog + addLog('debug', `[Transport] Received: ${JSON.stringify(message)}`) + // @ts-ignore + clientRef.current?.handleMessage(message) // Forward to current client + } + transportInstance.onerror = (err: Error) => { + // Transport errors usually mean connection is lost/failed definitively for this transport + // Use stable failConnection + failConnection(`Transport error (${transportType.toUpperCase()}): ${err.message}`, err) + // Should we return 'failed' here? failConnection sets state, maybe that's enough. + } + transportInstance.onclose = () => { + // Use refs for checks and stable callbacks + if (!isMountedRef.current || connectingRef.current) return // Ignore if connecting/unmounted + + addLog('info', `Transport connection closed (${successfulTransportRef.current || 'unknown'} type).`) + const currentState = stateRef.current + const currentAutoReconnect = autoReconnectRef.current + + if (currentState === 'ready' && currentAutoReconnect) { + const delay = typeof currentAutoReconnect === 'number' ? currentAutoReconnect : DEFAULT_RECONNECT_DELAY + addLog('info', `Attempting to reconnect in ${delay}ms...`) + setState('connecting') + setTimeout(() => { + if (isMountedRef.current) { + connect() // Start full connect logic again (will default to HTTP) + } + }, delay) + } else if (currentState !== 'failed' && currentState !== 'authenticating') { + // Use stable failConnection + failConnection('Connection closed unexpectedly.') + } + } + + // 3. Attempt client.connect() + try { + addLog('info', `Connecting client via ${transportType.toUpperCase()}...`) + await clientRef.current!.connect(transportInstance) + + // --- Success Path --- + addLog('info', `Client connected via ${transportType.toUpperCase()}. Loading tools...`) + successfulTransportRef.current = transportType // Store successful type + setState('loading') + + const toolsResponse = await clientRef.current!.request({ method: 'tools/list' }, ListToolsResultSchema) + + if (isMountedRef.current) { + // Check mount before final state updates + setTools(toolsResponse.tools) + addLog('info', `Loaded ${toolsResponse.tools.length} tools.`) + setState('ready') // Final success state + // connectingRef will be set to false after orchestration logic + connectAttemptRef.current = 0 // Reset on success + return 'success' + } else { + return 'failed' // Failed due to unmount after connect but before ready + } + } catch (connectErr) { + // --- Error Handling Path --- + addLog('debug', `Client connect error via ${transportType.toUpperCase()}:`, connectErr) + const errorInstance = connectErr instanceof Error ? connectErr : new Error(String(connectErr)) + + // Check for 404/405 specifically for HTTP transport + const is404 = errorInstance.message.includes('404') || errorInstance.message.includes('Not Found') + const is405 = errorInstance.message.includes('405') || errorInstance.message.includes('Method Not Allowed') + + if (transportType === 'http' && (is404 || is405)) { + addLog('warn', `HTTP transport failed (${is404 ? '404' : '405'}). Will attempt fallback to SSE.`) + return 'fallback' // Signal that fallback should be attempted + } + + // Check for Auth error (Simplified - requires more thought for interaction with fallback) + if ( + errorInstance instanceof UnauthorizedError || + errorInstance.message.includes('Unauthorized') || + errorInstance.message.includes('401') + ) { + addLog('info', 'Authentication required. Initiating SDK auth flow...') + // Ensure state is set only once if multiple attempts trigger auth + if (stateRef.current !== 'authenticating') { + setState('authenticating') + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) + authTimeoutRef.current = setTimeout(() => { + /* ... timeout logic ... */ + }, AUTH_TIMEOUT) + } + + 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 + + if (authResult === 'AUTHORIZED') { + addLog('info', 'Authentication successful via existing token or refresh. Re-attempting connection...') + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) + // Re-trigger the *entire* connect sequence after successful auth + // It will start with HTTP again. + // We return 'failed' here to stop current sequence, connect() below will handle restart. + // Set connectingRef false so outer connect call can proceed + connectingRef.current = false + connect() // Restart full connection sequence + return 'failed' // Stop this attempt sequence, new one started + } else if (authResult === 'REDIRECT') { + addLog('info', 'Redirecting for authentication. Waiting for callback...') + return 'auth_redirect' // Signal that we are waiting for redirect + } + } catch (sdkAuthError) { + if (!isMountedRef.current) return 'failed' + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) + // Use stable failConnection + failConnection( + `Failed to initiate authentication: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`, + sdkAuthError instanceof Error ? sdkAuthError : undefined, + ) + return 'failed' // Auth initiation failed + } + } + + // Handle other connection errors + // Use stable failConnection + failConnection(`Failed to connect via ${transportType.toUpperCase()}: ${errorInstance.message}`, errorInstance) + return 'failed' // Indicate definitive failure for this transport + } + } // End of tryConnectWithTransport helper + + // --- Orchestrate Connection Attempts --- + let finalStatus: 'success' | 'auth_redirect' | 'failed' | 'fallback' = 'failed' // Default to failed + + // 1. Try HTTP + const httpResult = await tryConnectWithTransport('http') + + // 2. Try SSE only if HTTP requested fallback and we haven't failed/redirected + if (httpResult === 'fallback' && isMountedRef.current && stateRef.current !== 'failed' && stateRef.current !== 'authenticating') { + const sseResult = await tryConnectWithTransport('sse') + finalStatus = sseResult // Use SSE result as final status + } else { + finalStatus = httpResult // Use HTTP result if no fallback was needed/possible } + + // --- Finalize Connection State --- + // Set connectingRef based on the final outcome. + // It should be false if 'success' or 'failed'. + // It should remain true if 'auth_redirect'. + if (finalStatus === 'success' || finalStatus === 'failed') { + connectingRef.current = false + } + // If finalStatus is 'auth_redirect', connectingRef remains true (set at the start). + + addLog('debug', `Connection sequence finished with status: ${finalStatus}`) }, [ // Stable callback dependencies - addLog, failConnection, - // Configuration dependencies that require full reconnect if changed - url, storageKeyPrefix, clientName, clientUri, callbackUrl, - // clientConfig changes might require reconnect? Add if necessary: - // clientConfig.name, clientConfig.version - // Note: connect does NOT depend on state or autoReconnect anymore - ]); - + addLog, + failConnection, + disconnect, + auth, // Include SDK auth function if used directly + // Configuration dependencies + url, + storageKeyPrefix, + clientName, + clientUri, + callbackUrl, + clientConfig.name, + clientConfig.version, + // No state/autoReconnect dependency here + ]) // callTool is stable (depends on stable addLog, failConnection, connect, and URL) const callTool = useCallback( async (name: string, args?: Record) => { // Use stateRef for check, state for throwing error message if (stateRef.current !== 'ready' || !clientRef.current) { - throw new Error(`MCP client is not ready (current state: ${state}). Cannot call tool "${name}".`); + throw new Error(`MCP client is not ready (current state: ${state}). Cannot call tool "${name}".`) } - addLog('info', `Calling tool: ${name}`, args); + addLog('info', `Calling tool: ${name}`, args) try { - const result = await clientRef.current.request( - { method: 'tools/call', params: { name, arguments: args } }, - CallToolResultSchema, - ); - addLog('info', `Tool "${name}" call successful:`, result); - return result; + const result = await clientRef.current.request({ method: 'tools/call', params: { name, arguments: args } }, CallToolResultSchema) + addLog('info', `Tool "${name}" call successful:`, result) + return result } catch (err) { - addLog('error', `Error calling tool "${name}": ${err instanceof Error ? err.message : String(err)}`, err); - const errorInstance = err instanceof Error ? err : new Error(String(err)); + addLog('error', `Error calling tool "${name}": ${err instanceof Error ? err.message : String(err)}`, err) + const errorInstance = err instanceof Error ? err : new Error(String(err)) - if (errorInstance instanceof UnauthorizedError || errorInstance.message.includes('Unauthorized') || errorInstance.message.includes('401')) { - addLog('warn', 'Tool call unauthorized, attempting re-authentication...'); - setState('authenticating'); // Update UI state - if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current); // Reset timeout - authTimeoutRef.current = setTimeout(() => { /* ... timeout logic ... */ }, AUTH_TIMEOUT); + if ( + errorInstance instanceof UnauthorizedError || + errorInstance.message.includes('Unauthorized') || + errorInstance.message.includes('401') + ) { + addLog('warn', 'Tool call unauthorized, attempting re-authentication...') + setState('authenticating') // Update UI state + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) // Reset timeout + authTimeoutRef.current = setTimeout(() => { + /* ... timeout logic ... */ + }, AUTH_TIMEOUT) try { - assert(authProviderRef.current, "Auth Provider not available for tool re-auth"); - const authResult = await auth(authProviderRef.current, { serverUrl: url }); + assert(authProviderRef.current, 'Auth Provider not available for tool re-auth') + const authResult = await auth(authProviderRef.current, { serverUrl: url }) - if (!isMountedRef.current) return; // Check mount + if (!isMountedRef.current) return // Check mount if (authResult === 'AUTHORIZED') { - addLog('info', 'Re-authentication successful. Retrying tool call is recommended, or reconnecting.'); - if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current); + addLog('info', 'Re-authentication successful. Retrying tool call is recommended, or reconnecting.') + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) // Option 1: Just set state ready and let user retry tool? // setState('ready'); // Option 2: Reconnect client completely? Safer. - connectingRef.current = false; - connect(); // Reconnect session + connectingRef.current = false + connect() // Reconnect session } else if (authResult === 'REDIRECT') { - addLog('info', 'Redirecting for re-authentication for tool call.'); + addLog('info', 'Redirecting for re-authentication for tool call.') // State is authenticating, wait for callback } } catch (sdkAuthError) { - if (!isMountedRef.current) return; - if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current); - failConnection(`Re-authentication failed: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`, sdkAuthError instanceof Error ? sdkAuthError : undefined); + if (!isMountedRef.current) return + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) + failConnection( + `Re-authentication failed: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`, + sdkAuthError instanceof Error ? sdkAuthError : undefined, + ) } } // Re-throw original error unless handled by re-auth redirect // @ts-ignore - if (stateRef.current !== 'authenticating') { // Only re-throw if not waiting for redirect - throw err; + if (stateRef.current !== 'authenticating') { + // Only re-throw if not waiting for redirect + throw err } // If authenticating, we might want to signal the caller differently, // but for now, we don't re-throw, assuming the UI will react to the 'authenticating' state. - return undefined; // Or indicate auth required? + return undefined // Or indicate auth required? } }, [state, url, addLog, failConnection, connect], // Depends on state for error message, url, and stable callbacks - ); - + ) // retry is stable (depends on stable addLog, connect) const retry = useCallback(() => { // Use stateRef for check if (stateRef.current === 'failed') { - addLog('info', 'Retry requested...'); + addLog('info', 'Retry requested...') // connect() will handle resetting state and error internally - connect(); + connect() } else { - addLog('warn', `Retry called but state is not 'failed' (state: ${stateRef.current}). Ignoring.`); + addLog('warn', `Retry called but state is not 'failed' (state: ${stateRef.current}). Ignoring.`) } - }, [addLog, connect]); // Depends only on stable callbacks - + }, [addLog, connect]) // Depends only on stable callbacks // authenticate is stable (depends on stable addLog, retry, connect) const authenticate = useCallback(() => { - addLog('info', 'Manual authentication requested...'); - const currentState = stateRef.current; // Use ref + addLog('info', 'Manual authentication requested...') + const currentState = stateRef.current // Use ref if (currentState === 'failed') { - addLog('info', 'Attempting to reconnect and authenticate via retry...'); - retry(); + addLog('info', 'Attempting to reconnect and authenticate via retry...') + retry() } else if (currentState === 'authenticating') { - addLog('warn', 'Already attempting authentication. Check for blocked popups or wait for timeout.'); - const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl(); - if (manualUrl && !authUrl) { // Use component state `authUrl` here - setAuthUrl(manualUrl); - addLog('info', 'Manual authentication URL retrieved:', manualUrl); + addLog('warn', 'Already attempting authentication. Check for blocked popups or wait for timeout.') + const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl() + if (manualUrl && !authUrl) { + // Use component state `authUrl` here + setAuthUrl(manualUrl) + addLog('info', 'Manual authentication URL retrieved:', manualUrl) } } else { - addLog('info', `Client not in a state requiring manual authentication trigger (state: ${currentState}). If needed, try disconnecting and reconnecting.`); + addLog( + 'info', + `Client not in a state requiring manual authentication trigger (state: ${currentState}). If needed, try disconnecting and reconnecting.`, + ) // Optionally, force re-auth even if ready? // addLog('info', 'Forcing re-authentication...'); // setState('authenticating'); // 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]) // Depends on stable callbacks and authUrl state // clearStorage is stable (depends on stable addLog, disconnect) const clearStorage = useCallback(() => { if (authProviderRef.current) { - const count = authProviderRef.current.clearStorage(); - addLog('info', `Cleared ${count} item(s) from localStorage for ${url}.`); - setAuthUrl(undefined); // Clear manual URL state + const count = authProviderRef.current.clearStorage() + addLog('info', `Cleared ${count} item(s) from localStorage for ${url}.`) + setAuthUrl(undefined) // Clear manual URL state // Disconnect should reset state appropriately - disconnect(); + disconnect() } else { - addLog('warn', 'Auth provider not initialized, cannot clear storage.'); + addLog('warn', 'Auth provider not initialized, cannot clear storage.') } - }, [url, addLog, disconnect]); // Depends on url and stable callbacks - + }, [url, addLog, disconnect]) // Depends on url and stable callbacks // ===== Effects ===== // Effect for handling auth callback messages from popup (Stable dependencies) useEffect(() => { const messageHandler = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return; + if (event.origin !== window.location.origin) return if (event.data?.type === 'mcp_auth_callback') { - addLog('info', 'Received auth callback message.', event.data); - if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current); + addLog('info', 'Received auth callback message.', event.data) + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) if (event.data.success) { - addLog('info', 'Authentication successful via popup. Reconnecting client...'); - connectingRef.current = false; - connect(); // Call stable connect + addLog('info', 'Authentication successful via popup. Reconnecting client...') + connectingRef.current = false + connect() // Call stable connect } else { - failConnection(`Authentication failed in callback: ${event.data.error || 'Unknown reason.'}`); // Call stable failConnection + failConnection(`Authentication failed in callback: ${event.data.error || 'Unknown reason.'}`) // Call stable failConnection } } - }; - window.addEventListener('message', messageHandler); - addLog('debug', 'Auth callback message listener added.'); - return () => { - window.removeEventListener('message', messageHandler); - addLog('debug', 'Auth callback message listener removed.'); - if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current); - }; - // Dependencies are stable callbacks - }, [addLog, failConnection, connect]); - - // Effect for initial connection (Stable dependencies) - useEffect(() => { - isMountedRef.current = true; - addLog('debug', 'useMcp mounted, initiating connection.'); - connectAttemptRef.current = 0; - // Initialize provider here if it doesn't exist, ensuring it uses latest options - if (!authProviderRef.current || authProviderRef.current.serverUrl !== url) { - authProviderRef.current = new BrowserOAuthClientProvider(url, { - storageKeyPrefix, clientName, clientUri, callbackUrl - }); - addLog('debug', 'BrowserOAuthClientProvider initialized/updated on mount/option change.'); } - connect(); // Call stable connect - + window.addEventListener('message', messageHandler) + addLog('debug', 'Auth callback message listener added.') return () => { - isMountedRef.current = false; - addLog('debug', 'useMcp unmounting, disconnecting.'); - disconnect(true); // Call stable disconnect - }; - // Ensure all config options that influence provider/connect are dependencies - }, [ - url, storageKeyPrefix, callbackUrl, clientName, clientUri, // Core config - clientConfig.name, clientConfig.version, // Include client config if it affects Client init - connect, disconnect // Stable callbacks are OK as deps - ]); + window.removeEventListener('message', messageHandler) + addLog('debug', 'Auth callback message listener removed.') + if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) + } + // Dependencies are stable callbacks + }, [addLog, failConnection, connect]) - // Effect for auto-retry logic (Stable dependencies) + // Initial Connection (depends on config and stable callbacks) useEffect(() => { - let retryTimeoutId: number | null = null; + /* ... as before, calls stable connect/disconnect ... */ + isMountedRef.current = true + addLog('debug', 'useMcp mounted, initiating connection.') + connectAttemptRef.current = 0 + // Initialize provider here if needed + if (!authProviderRef.current || authProviderRef.current.serverUrl !== url) { + /* ... init provider ... */ + addLog('debug', 'BrowserOAuthClientProvider initialized/updated on mount/option change.') + } + connect() // Call stable connect + return () => { + isMountedRef.current = false + addLog('debug', 'useMcp unmounting, disconnecting.') + disconnect(true) // Call stable disconnect + } + }, [ + url, + storageKeyPrefix, + callbackUrl, + clientName, + clientUri, + clientConfig.name, + clientConfig.version, + connect, + disconnect, // Stable callbacks + ]) + + // Auto-Retry (depends on state, config, stable callbacks) + useEffect(() => { + let retryTimeoutId: number | null = null // Use state directly here, as this effect *should* run when state changes to 'failed' if (state === 'failed' && autoRetry && connectAttemptRef.current > 0) { - const delay = typeof autoRetry === 'number' ? autoRetry : DEFAULT_RETRY_DELAY; - addLog('info', `Connection failed, auto-retrying in ${delay}ms...`); + const delay = typeof autoRetry === 'number' ? autoRetry : DEFAULT_RETRY_DELAY + addLog('info', `Connection failed, auto-retrying in ${delay}ms...`) retryTimeoutId = setTimeout(() => { // Check mount status and state again before retrying if (isMountedRef.current && stateRef.current === 'failed') { - retry(); // Call stable retry + retry() // Call stable retry } - }, delay); + }, delay) } return () => { - if (retryTimeoutId) clearTimeout(retryTimeoutId); - }; + if (retryTimeoutId) clearTimeout(retryTimeoutId) + } // Depends on state (to trigger), autoRetry config, and stable retry/addLog callbacks - }, [state, autoRetry, retry, addLog]); - + }, [state, autoRetry, retry, addLog]) + // --- Return Public API --- return { state, tools, @@ -486,5 +575,5 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { disconnect, authenticate, clearStorage, - }; -} \ No newline at end of file + } +}