From fa463fecb1e4235af8c7fa25bfc9b8f3c470ec2b Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Wed, 7 May 2025 11:11:46 +1000 Subject: [PATCH 1/5] 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 + } +} From d6dbb4c0c40ff7d3cc2a13345275e4b4b85efe99 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Wed, 7 May 2025 11:18:47 +1000 Subject: [PATCH 2/5] Giving AMP a go too --- AGENT.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 AGENT.md diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..759766b --- /dev/null +++ b/AGENT.md @@ -0,0 +1,32 @@ +# use-mcp Project Guidelines + +## Build, Lint, and Test Commands +- `pnpm dev`: Run development build with watch mode +- `pnpm build`: Build the project +- `pnpm check`: Run prettier checks and TypeScript type checking + +## Code Style Guidelines + +### Imports +- Use explicit .js extensions in imports (ES modules style) +- Group imports: SDK imports first, followed by React/external deps, then local imports + +### Formatting +- Single quotes for strings +- No semicolons at line ends +- 140 character line width +- Use 2 space indentation + +### Types and Naming +- Strong typing with TypeScript +- Descriptive interface names with camelCase for variables/functions and PascalCase for types +- Comprehensive JSDoc comments for public API functions and types + +### Error Handling +- Use assertions with descriptive messages +- Log errors with appropriate levels (debug, info, warn, error) +- Defensive error handling with specific error types when available + +### React Patterns +- Use React hooks with useRef for mutable values +- Stable callbacks with useCallback and appropriate dependencies \ No newline at end of file From a5d247f0a1114bf6d5029b587b5160c2fce653af Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Wed, 7 May 2025 11:26:29 +1000 Subject: [PATCH 3/5] lol gemini --- src/react/useMcp.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/react/useMcp.ts b/src/react/useMcp.ts index 5bc9b46..ba3f28d 100644 --- a/src/react/useMcp.ts +++ b/src/react/useMcp.ts @@ -145,11 +145,19 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { addLog('info', `Connecting attempt #${connectAttemptRef.current} to ${url}...`) // Initialize provider/client if needed (idempotent) + // Ensure provider/client are initialized (idempotent check) if (!authProviderRef.current) { - /* ... init provider ... */ + authProviderRef.current = new BrowserOAuthClientProvider(url, { + storageKeyPrefix, clientName, clientUri, callbackUrl + }); + addLog('debug', 'BrowserOAuthClientProvider initialized in connect.'); } if (!clientRef.current) { - /* ... init client ... */ + 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.'); } // --- Helper function for a single connection attempt --- @@ -522,7 +530,9 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { connectAttemptRef.current = 0 // Initialize provider here if needed if (!authProviderRef.current || authProviderRef.current.serverUrl !== url) { - /* ... init provider ... */ + authProviderRef.current = new BrowserOAuthClientProvider(url, { + storageKeyPrefix, clientName, clientUri, callbackUrl + }); addLog('debug', 'BrowserOAuthClientProvider initialized/updated on mount/option change.') } connect() // Call stable connect From 3bf0627ca6cd86ae5d771c3ca48e9be35f07c83f Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Wed, 7 May 2025 16:01:29 +1000 Subject: [PATCH 4/5] Falling back on CORS errors too --- src/react/useMcp.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/react/useMcp.ts b/src/react/useMcp.ts index ba3f28d..df12141 100644 --- a/src/react/useMcp.ts +++ b/src/react/useMcp.ts @@ -2,7 +2,7 @@ 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 { SSEClientTransport, SSEClientTransportOptions } 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' @@ -181,7 +181,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { transportRef.current = null } - const commonOptions = { authProvider: authProviderRef.current } + const commonOptions: SSEClientTransportOptions = { authProvider: authProviderRef.current } const targetUrl = new URL(url) if (transportType === 'http') { @@ -266,19 +266,21 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { 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') + const errorMessage = errorInstance.message + const is404 = errorMessage.includes('404') || errorMessage.includes('Not Found') + const is405 = errorMessage.includes('405') || errorMessage.includes('Method Not Allowed') + const isLikelyCors = errorMessage === 'Failed to fetch' /* Chrome */ || errorMessage === 'NetworkError when attempting to fetch resource.' /* Firefox */ || errorMessage === 'Load failed' /* Safari */ - if (transportType === 'http' && (is404 || is405)) { - addLog('warn', `HTTP transport failed (${is404 ? '404' : '405'}). Will attempt fallback to SSE.`) + if (transportType === 'http' && (is404 || is405 || isLikelyCors)) { + addLog('warn', `HTTP transport failed (${isLikelyCors ? 'CORS' : 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') + errorMessage.includes('Unauthorized') || + errorMessage.includes('401') ) { addLog('info', 'Authentication required. Initiating SDK auth flow...') // Ensure state is set only once if multiple attempts trigger auth @@ -324,8 +326,8 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { // 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 + failConnection(`Failed to connect via ${transportType.toUpperCase()}: ${errorMessage}`, errorInstance) + return 'fallback' // If our logic above is wrong, we'd prevent HTTPs servers from ever working. So always fall back as a last resort. } } // End of tryConnectWithTransport helper From 5d7e5c7aa14b0a1585b40ebfc405da90d9e4f57a Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Wed, 7 May 2025 16:02:43 +1000 Subject: [PATCH 5/5] Regenerated the README --- README.md | 187 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 121 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 7619400..5ad45c2 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,145 @@ # use-mcp -**⚠️ Experimental: This library is new and under active development. Expect breaking changes. Please report all issues to [https://github.com/geelen/use-mcp/issues](https://github.com/geelen/use-mcp/issues).** - -`use-mcp` provides browser-based utilities for connecting to servers implementing the [Model Context Protocol (MCP)](https://github.com/modelcontext/protocol). It simplifies handling connections, managing authentication (including OAuth 2.0 Authorization Code Flow with PKCE suitable for public clients like web apps), and accessing resources like Tools provided by the MCP server. - -This library is designed to be used alongside the Vercel AI SDK (`ai`) or similar libraries, allowing you to easily integrate MCP tools into your LLM application's frontend. - -## Features - -* Connects to MCP servers via Server-Sent Events (SSE). -* Handles MCP JSON-RPC messaging. -* Implements browser-friendly OAuth 2.0 authentication (Authorization Code with PKCE) using popups and `localStorage`. -* Provides framework-agnostic auth utilities. -* Includes a React hook (`useMcp`) for easy integration into React applications (via `use-mcp/react`). +A lightweight React hook for connecting to [Model Context Protocol (MCP)](https://github.com/modelcontextprotocol) servers. Simplifies authentication and tool calling for AI systems implementing the MCP standard. ## Installation ```bash npm install use-mcp # or -yarn add use-mcp -# or pnpm add use-mcp +# or +yarn add use-mcp ``` -## Util Exports +## Features -This main package (use-mcp) exports utilities primarily for handling OAuth in the browser: +- 🔄 Automatic connection management with reconnection and retries +- 🔐 OAuth authentication flow handling with popup and fallback support +- 📦 Simple React hook interface for MCP integration +- 🧰 TypeScript types for editor assistance and type checking +- 📝 Comprehensive logging for debugging +- 🌐 Works with both HTTP and SSE (Server-Sent Events) transports -* `BrowserOAuthClientProvider`: An implementation of the OAuthClientProvider interface from @modelcontextprotocol/sdk. It uses localStorage to store client information and tokens and handles the popup-based authorization flow. It's used internally by the useMcp hook but can be used independently if needed. - -* `onMcpAuthorization`: A function designed to be called on your OAuth callback page (e.g., /oauth/callback). It handles exchanging the authorization code (received from the auth server) for tokens, validating the state parameter, storing tokens using localStorage, and communicating success or failure back to the original application window that initiated the auth flow. - -## React Integration (use-mcp/react) - -For React applications, the easiest way to use this library is via the useMcp hook. - -See the [src/react/README.md](src/react/README.md) for detailed usage instructions and examples. - -## Basic OAuth Callback Setup - -You need an endpoint in your application (e.g., /oauth/callback) that the OAuth server redirects the user back to after they approve the authorization request. This endpoint should call onMcpAuthorization. - -### Example (Conceptual - using a simple script in HTML or a framework handler): +## Quick Start ```tsx -// pages/oauth/callback.js (or similar route file) -import { useEffect } from 'react'; // Or framework equivalent -import { onMcpAuthorization } from 'use-mcp'; +import { useMcp } from 'use-mcp/react' -export default function OAuthCallbackPage() { - useEffect(() => { - // Extract query parameters from window.location.search - const queryParams = Object.fromEntries( - new URLSearchParams(window.location.search).entries() - ); +function MyAIComponent() { + const { + state, // Connection state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed' + tools, // Available tools from MCP server + error, // Error message if connection failed + callTool, // Function to call tools on the MCP server + retry, // Retry connection manually + authenticate, // Manually trigger authentication + clearStorage, // Clear stored tokens and credentials + } = useMcp({ + url: 'https://your-mcp-server.com', + clientName: 'My App', + autoReconnect: true, + }) - // Call the handler - onMcpAuthorization(queryParams, { - // Optional: specify storage key prefix if not using default 'mcp:auth' - // storageKeyPrefix: 'my_custom_prefix' - }).then(result => { - if (!result.success) { - // Error already logged and displayed by onMcpAuthorization - console.error("OAuth callback failed:", result.error); - // Optionally show a persistent error message or redirect - } - // On success, onMcpAuthorization closes the window or redirects - }).catch(err => { - // Catch unexpected errors during the handler execution itself - console.error("Unexpected error in OAuth callback handler:", err); - // Display error in the popup/page - document.body.innerHTML = `

Error

An unexpected error occurred during authentication.

${err.message}
`; - }); + // Handle different states + if (state === 'failed') { + return ( +
+

Connection failed: {error}

+ + +
+ ) + } - }, []); // Run only once on component mount + if (state !== 'ready') { + return
Connecting to AI service...
+ } - // Render a loading state while the exchange happens - return
Processing authentication...
; + // Use available tools + const handleSearch = async () => { + try { + const result = await callTool('search', { query: 'example search' }) + console.log('Search results:', result) + } catch (err) { + console.error('Tool call failed:', err) + } + } + + return ( +
+

Available Tools: {tools.length}

+
    + {tools.map(tool => ( +
  • {tool.name}
  • + ))} +
+ +
+ ) } ``` -Future Plans +## Setting Up OAuth Callback -* Support for other frameworks (Vue, Svelte, etc.). -* Improved error handling and recovery. -* Support for different OAuth flows if necessary. +To handle the OAuth authentication flow, you need to set up a callback endpoint in your app: -Contributions and feedback are welcome! \ No newline at end of file +```tsx +// pages/oauth/callback.tsx or equivalent +import { useEffect } from 'react' +import { onMcpAuthorization } from 'use-mcp' + +export default function OAuthCallbackPage() { + useEffect(() => { + onMcpAuthorization() + }, []) + + return ( +
+

Authenticating...

+

This window should close automatically.

+
+ ) +} +``` + +## API Reference + +### `useMcp` Hook + +```tsx +function useMcp(options: UseMcpOptions): UseMcpResult +``` + +#### Options + +| Option | Type | Description | +|--------|------|-------------| +| `url` | `string` | **Required**. URL of your MCP server | +| `clientName` | `string` | Name of your client for OAuth registration | +| `clientUri` | `string` | URI of your client for OAuth registration | +| `callbackUrl` | `string` | Custom callback URL for OAuth redirect (defaults to `/oauth/callback` on the current origin) | +| `storageKeyPrefix` | `string` | Storage key prefix for OAuth data in localStorage (defaults to "mcp:auth") | +| `clientConfig` | `object` | Custom configuration for the MCP client identity | +| `debug` | `boolean` | Whether to enable verbose debug logging | +| `autoRetry` | `boolean \| number` | Auto retry connection if initial connection fails, with delay in ms | +| `autoReconnect` | `boolean \| number` | Auto reconnect if an established connection is lost, with delay in ms (default: 3000) | + +#### Return Value + +| Property | Type | Description | +|----------|------|-------------| +| `state` | `string` | Current connection state: 'discovering', 'authenticating', 'connecting', 'loading', 'ready', 'failed' | +| `tools` | `Tool[]` | Available tools from the MCP server | +| `error` | `string \| undefined` | Error message if connection failed | +| `authUrl` | `string \| undefined` | Manual authentication URL if popup is blocked | +| `log` | `{ level: 'debug' \| 'info' \| 'warn' \| 'error'; message: string; timestamp: number }[]` | Array of log messages | +| `callTool` | `(name: string, args?: Record) => Promise` | Function to call a tool on the MCP server | +| `retry` | `() => void` | Manually attempt to reconnect | +| `disconnect` | `() => void` | Disconnect from the MCP server | +| `authenticate` | `() => void` | Manually trigger authentication | +| `clearStorage` | `() => void` | Clear all stored authentication data | + +## License + +MIT \ No newline at end of file