fixed the effect looping, can call an MCP function without auth
This commit is contained in:
parent
d0372fd8d8
commit
d9454bcdbf
2 changed files with 350 additions and 337 deletions
|
|
@ -4,8 +4,6 @@ import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
|||
// Assuming StoredState is defined in ./types.js and includes fields for provider options
|
||||
import { StoredState } from './types.js'; // Adjust path if necessary
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Browser-compatible OAuth client provider for MCP using localStorage.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,18 +1,16 @@
|
|||
// 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 the main auth function and error type
|
||||
import { auth, UnauthorizedError, OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
// Keep BrowserOAuthClientProvider
|
||||
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 { 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
|
||||
|
||||
const DEFAULT_RECONNECT_DELAY = 3000
|
||||
const DEFAULT_RETRY_DELAY = 5000
|
||||
const AUTH_TIMEOUT = 5 * 60 * 1000 // 5 minutes for user to complete auth in popup
|
||||
const DEFAULT_RECONNECT_DELAY = 3000;
|
||||
const DEFAULT_RETRY_DELAY = 5000;
|
||||
const AUTH_TIMEOUT = 5 * 60 * 1000;
|
||||
|
||||
export function useMcp(options: UseMcpOptions): UseMcpResult {
|
||||
const {
|
||||
|
|
@ -22,454 +20,471 @@ 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,
|
||||
debug = false, // Consider using this in addLog
|
||||
autoRetry = false,
|
||||
autoReconnect = DEFAULT_RECONNECT_DELAY,
|
||||
// popupFeatures is now handled within BrowserOAuthClientProvider
|
||||
} = options
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<UseMcpResult['state']>('discovering')
|
||||
const [tools, setTools] = useState<Tool[]>([])
|
||||
const [error, setError] = useState<string | undefined>(undefined)
|
||||
const [log, setLog] = useState<UseMcpResult['log']>([])
|
||||
// Store the URL for manual fallback if needed
|
||||
const [authUrl, setAuthUrl] = useState<string | undefined>(undefined)
|
||||
const [state, setState] = useState<UseMcpResult['state']>('discovering');
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [log, setLog] = useState<UseMcpResult['log']>([]);
|
||||
const [authUrl, setAuthUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
const clientRef = useRef<Client | null>(null)
|
||||
const transportRef = useRef<SSEClientTransport | null>(null)
|
||||
const authProviderRef = useRef<BrowserOAuthClientProvider | null>(null)
|
||||
const connectingRef = useRef<boolean>(false)
|
||||
const isMountedRef = useRef<boolean>(true)
|
||||
const connectAttemptRef = useRef<number>(0)
|
||||
const authTimeoutRef = useRef<number | null>(null) // Use number for type safety
|
||||
const clientRef = useRef<Client | null>(null);
|
||||
const transportRef = useRef<SSEClientTransport | null>(null);
|
||||
const authProviderRef = useRef<BrowserOAuthClientProvider | null>(null);
|
||||
const connectingRef = useRef<boolean>(false);
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
const connectAttemptRef = useRef<number>(0);
|
||||
const authTimeoutRef = useRef<number | null>(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);
|
||||
|
||||
// --- Effect to keep refs updated ---
|
||||
useEffect(() => {
|
||||
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[]) => {
|
||||
const fullMessage = args.length > 0 ? `${message} ${args.map((arg) => JSON.stringify(arg)).join(' ')}` : message
|
||||
console[level](`[useMcp] ${fullMessage}`)
|
||||
// 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}`);
|
||||
// Use isMountedRef to prevent state updates after unmount
|
||||
if (isMountedRef.current) {
|
||||
setLog((prevLog) => [...prevLog.slice(-100), { level, message: fullMessage, timestamp: Date.now() }]) // Limit log size
|
||||
setLog((prevLog) => [...prevLog.slice(-100), { level, message: fullMessage, timestamp: Date.now() }]);
|
||||
}
|
||||
},
|
||||
[], // No dependencies needed if `debug` isn't used inside
|
||||
)
|
||||
[], // 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 client = clientRef.current
|
||||
const transport = transportRef.current
|
||||
clientRef.current = null
|
||||
transportRef.current = null // Prevent reconnection logic on explicit disconnect
|
||||
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],
|
||||
)
|
||||
[addLog], // Depends only on stable addLog
|
||||
);
|
||||
|
||||
const failConnection = useCallback(
|
||||
(errorMessage: string, connectionError?: Error) => {
|
||||
addLog('error', errorMessage, connectionError ?? '')
|
||||
if (isMountedRef.current) {
|
||||
setState('failed')
|
||||
setError(errorMessage)
|
||||
// Attempt to get manual auth URL if provider exists
|
||||
const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl()
|
||||
if (manualUrl) {
|
||||
setAuthUrl(manualUrl)
|
||||
addLog('info', 'Manual authentication URL may be available.', manualUrl)
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
connectingRef.current = false
|
||||
// Don't disconnect here, allow user to potentially retry/authenticate
|
||||
// disconnect(true); // No longer needed, state is 'failed'
|
||||
},
|
||||
[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 () => {
|
||||
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;
|
||||
}
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
connectingRef.current = true
|
||||
connectAttemptRef.current += 1
|
||||
setError(undefined)
|
||||
setAuthUrl(undefined) // Clear previous manual URL
|
||||
setState('discovering')
|
||||
addLog('info', `Connecting attempt #${connectAttemptRef.current} to ${url}...`)
|
||||
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}...`);
|
||||
|
||||
// 1. Initialize Auth Provider (only once)
|
||||
// Ensure provider/client are initialized (idempotent check)
|
||||
if (!authProviderRef.current) {
|
||||
authProviderRef.current = new BrowserOAuthClientProvider(url, {
|
||||
storageKeyPrefix,
|
||||
clientName,
|
||||
clientUri,
|
||||
callbackUrl,
|
||||
})
|
||||
addLog('debug', 'BrowserOAuthClientProvider initialized.')
|
||||
storageKeyPrefix, clientName, clientUri, callbackUrl
|
||||
});
|
||||
addLog('debug', 'BrowserOAuthClientProvider initialized in connect.');
|
||||
}
|
||||
|
||||
// 2. Initialize MCP Client (only once)
|
||||
if (!clientRef.current) {
|
||||
clientRef.current = new Client(
|
||||
{
|
||||
name: clientConfig.name || 'use-mcp-react-client',
|
||||
version: clientConfig.version || '0.1.0',
|
||||
},
|
||||
{ name: clientConfig.name || 'use-mcp-react-client', version: clientConfig.version || '0.1.0' },
|
||||
{ capabilities: {} },
|
||||
)
|
||||
addLog('debug', 'MCP Client initialized.')
|
||||
);
|
||||
addLog('debug', 'MCP Client initialized in connect.');
|
||||
}
|
||||
|
||||
// 3. Create/Recreate SSE Transport
|
||||
setState('connecting')
|
||||
// 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
|
||||
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, // Pass provider for automatic token attachment
|
||||
})
|
||||
addLog('debug', 'SSEClientTransport created.')
|
||||
} catch (err) {
|
||||
failConnection(
|
||||
`Failed to create transport: ${err instanceof Error ? err.message : String(err)}`,
|
||||
err instanceof Error ? err : undefined,
|
||||
)
|
||||
return
|
||||
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
|
||||
}
|
||||
|
||||
// 4. Set up Transport Handlers
|
||||
// --- 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)}`)
|
||||
addLog('debug', `[Transport] Received: ${JSON.stringify(message)}`);
|
||||
// Forward to the *current* client instance
|
||||
// @ts-ignore
|
||||
clientRef.current?.handleMessage(message)
|
||||
}
|
||||
clientRef.current?.handleMessage(message);
|
||||
};
|
||||
transportRef.current.onerror = (err: Error) => {
|
||||
// Transport errors usually mean connection is lost/failed
|
||||
failConnection(`Transport error: ${err.message}`, err)
|
||||
}
|
||||
failConnection(`Transport error: ${err.message}`, err);
|
||||
};
|
||||
transportRef.current.onclose = () => {
|
||||
if (!isMountedRef.current || connectingRef.current) return // Ignore if unmounting or mid-connect
|
||||
// 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 = state // Capture state at time of closure
|
||||
if (currentState === 'ready' && autoReconnect) {
|
||||
const delay = typeof autoReconnect === 'number' ? autoReconnect : DEFAULT_RECONNECT_DELAY
|
||||
addLog('info', `Attempting to reconnect in ${delay}ms...`)
|
||||
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(() => {
|
||||
if (isMountedRef.current && clientRef.current /* Check if still relevant */) {
|
||||
connect() // Re-run the connection logic
|
||||
// Check mounted status again before actually connecting
|
||||
if (isMountedRef.current) {
|
||||
connect(); // Call stable connect again
|
||||
}
|
||||
}, delay)
|
||||
setState('connecting')
|
||||
}, delay);
|
||||
} else if (currentState !== 'failed' && currentState !== 'authenticating') {
|
||||
// If closed unexpectedly and not already failed/authenticating
|
||||
failConnection('Connection closed unexpectedly.')
|
||||
failConnection('Connection closed unexpectedly.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Connect Client & Handle Auth
|
||||
// --- Connect Client & Handle Auth ---
|
||||
try {
|
||||
addLog('info', 'Connecting client...')
|
||||
assert(clientRef.current, 'Client not initialized')
|
||||
assert(transportRef.current, 'Transport not initialized')
|
||||
addLog('info', 'Connecting client...');
|
||||
// Client/transport refs are already asserted above
|
||||
await clientRef.current!.connect(transportRef.current);
|
||||
|
||||
await clientRef.current.connect(transportRef.current)
|
||||
addLog('info', 'Client connected. Loading tools...');
|
||||
setState('loading'); // Update state
|
||||
|
||||
addLog('info', 'Client connected. Loading tools...')
|
||||
setState('loading')
|
||||
|
||||
const toolsResponse = await clientRef.current.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||
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')
|
||||
connectingRef.current = false
|
||||
connectAttemptRef.current = 0 // Reset on success
|
||||
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))
|
||||
addLog('debug', 'Client connect error:', connectErr);
|
||||
const errorInstance = connectErr instanceof Error ? connectErr : new Error(String(connectErr));
|
||||
|
||||
// Check if it's specifically an UnauthorizedError or smells like one
|
||||
// NOTE: The SSE Transport might wrap the underlying 401. Check for UnauthorizedError first.
|
||||
if (
|
||||
errorInstance instanceof UnauthorizedError ||
|
||||
errorInstance.message.includes('Unauthorized') ||
|
||||
errorInstance.message.includes('401')
|
||||
) {
|
||||
addLog('info', 'Authentication required. Initiating SDK auth flow...')
|
||||
setState('authenticating')
|
||||
// Start auth timeout timer
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
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(() => {
|
||||
addLog('warn', 'Authentication timed out.')
|
||||
if (isMountedRef.current && state === 'authenticating') {
|
||||
// Check state again
|
||||
failConnection('Authentication timed out. Please try again or use the manual link if available.')
|
||||
// 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)
|
||||
}, AUTH_TIMEOUT);
|
||||
|
||||
try {
|
||||
assert(authProviderRef.current, 'Auth Provider not available for auth flow')
|
||||
// This call will handle discovery, checks, token refresh attempts,
|
||||
// and finally trigger provider.redirectToAuthorization if needed.
|
||||
const authResult = await auth(authProviderRef.current, { serverUrl: url })
|
||||
// If auth() resolves with "AUTHORIZED", it means refresh worked or existing token is valid.
|
||||
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 transport...')
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) // Stop timeout
|
||||
// We might need to reconnect the transport or the whole client
|
||||
// Let's try reconnecting the client fully
|
||||
connectingRef.current = false // Allow reconnect
|
||||
connect() // Re-initiate connection flow
|
||||
}
|
||||
// If authResult is 'REDIRECT', the provider's redirectToAuthorization was called.
|
||||
// We are now waiting for the popup callback. State is already 'authenticating'.
|
||||
else if (authResult === 'REDIRECT') {
|
||||
addLog('info', 'Redirecting for authentication. Waiting for callback...')
|
||||
// The state is 'authenticating', timeout is running.
|
||||
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) {
|
||||
// Handle errors from the SDK's auth() function itself (e.g., metadata fetch fail)
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
failConnection(
|
||||
`Failed to initiate authentication: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`,
|
||||
sdkAuthError instanceof Error ? sdkAuthError : undefined,
|
||||
)
|
||||
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
|
||||
}
|
||||
} else {
|
||||
// Handle other connection errors
|
||||
failConnection(`Failed to connect: ${errorInstance.message}`, errorInstance)
|
||||
}
|
||||
// Set connectingRef false only if not waiting for redirect
|
||||
if (state !== 'authenticating') {
|
||||
connectingRef.current = false
|
||||
failConnection(`Failed to connect: ${errorInstance.message}`, errorInstance);
|
||||
// connectingRef set to false inside failConnection
|
||||
}
|
||||
// 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.
|
||||
}
|
||||
}, [
|
||||
url,
|
||||
storageKeyPrefix,
|
||||
clientName,
|
||||
clientUri,
|
||||
callbackUrl,
|
||||
clientConfig, // Provider/Client configs
|
||||
addLog,
|
||||
failConnection,
|
||||
disconnect, // Core actions
|
||||
state,
|
||||
autoReconnect, // State/Config used in handlers
|
||||
])
|
||||
// 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
|
||||
]);
|
||||
|
||||
|
||||
// callTool is stable (depends on stable addLog, failConnection, connect, and URL)
|
||||
const callTool = useCallback(
|
||||
async (name: string, args?: Record<string, unknown>) => {
|
||||
if (state !== 'ready' || !clientRef.current) {
|
||||
throw new Error(`MCP client is not ready (state: ${state}). Cannot call tool "${name}".`)
|
||||
// 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}".`);
|
||||
}
|
||||
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)
|
||||
// TODO: Check if error is UnauthorizedError and trigger re-authentication?
|
||||
if (
|
||||
err instanceof UnauthorizedError ||
|
||||
(err instanceof Error && (err.message.includes('Unauthorized') || err.message.includes('401')))
|
||||
) {
|
||||
addLog('warn', 'Tool call unauthorized, attempting re-authentication...')
|
||||
setState('authenticating') // Signal auth needed
|
||||
// Don't disconnect, just trigger auth
|
||||
assert(authProviderRef.current, 'Auth Provider not available for tool re-auth')
|
||||
auth(authProviderRef.current, { serverUrl: url }).catch((sdkAuthError) => {
|
||||
failConnection(
|
||||
`Re-authentication failed: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`,
|
||||
sdkAuthError instanceof Error ? sdkAuthError : undefined,
|
||||
)
|
||||
})
|
||||
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);
|
||||
|
||||
try {
|
||||
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 (authResult === 'AUTHORIZED') {
|
||||
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
|
||||
} else if (authResult === 'REDIRECT') {
|
||||
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);
|
||||
}
|
||||
}
|
||||
throw err
|
||||
// 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 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?
|
||||
}
|
||||
},
|
||||
[state, addLog, url], // Add url dependency for re-auth attempt
|
||||
)
|
||||
[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(() => {
|
||||
// Retry should attempt to connect again, regardless of the specific failure reason
|
||||
if (state === 'failed') {
|
||||
addLog('info', 'Retry requested...')
|
||||
// Reset state and attempt connection
|
||||
setState('discovering')
|
||||
setError(undefined)
|
||||
setAuthUrl(undefined)
|
||||
connect()
|
||||
// Use stateRef for check
|
||||
if (stateRef.current === 'failed') {
|
||||
addLog('info', 'Retry requested...');
|
||||
// connect() will handle resetting state and error internally
|
||||
connect();
|
||||
} else {
|
||||
addLog('warn', `Retry called but state is not 'failed' (state: ${state}). Ignoring.`)
|
||||
addLog('warn', `Retry called but state is not 'failed' (state: ${stateRef.current}). Ignoring.`);
|
||||
}
|
||||
}, [state, addLog, connect])
|
||||
}, [addLog, connect]); // Depends only on stable callbacks
|
||||
|
||||
|
||||
// authenticate is stable (depends on stable addLog, retry, connect)
|
||||
const authenticate = useCallback(() => {
|
||||
addLog('info', 'Manual authentication requested...')
|
||||
// Option 1: If failed, just retry connection which includes auth logic
|
||||
if (state === 'failed') {
|
||||
addLog('info', 'Attempting to reconnect and authenticate...')
|
||||
retry() // Retry will call connect()
|
||||
}
|
||||
// Option 2: If stuck authenticating (popup possibly blocked), maybe re-trigger
|
||||
else if (state === 'authenticating') {
|
||||
addLog('warn', 'Already attempting authentication. Check for blocked popups or wait for timeout.')
|
||||
// Optionally, try retrieving and setting the manual URL again
|
||||
const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl()
|
||||
if (manualUrl && !authUrl) {
|
||||
// Only set if not already set
|
||||
setAuthUrl(manualUrl)
|
||||
addLog('info', 'Manual authentication URL retrieved:', manualUrl)
|
||||
addLog('info', 'Manual authentication requested...');
|
||||
const currentState = stateRef.current; // Use ref
|
||||
|
||||
if (currentState === 'failed') {
|
||||
addLog('info', 'Attempting to reconnect and authenticate via retry...');
|
||||
retry();
|
||||
} else if (currentState === '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);
|
||||
}
|
||||
// Maybe try calling SDK auth again? Could be risky if popup is open.
|
||||
// assert(authProviderRef.current, "Auth Provider not available for manual auth trigger");
|
||||
} else {
|
||||
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);
|
||||
}
|
||||
// Option 3: If already ready, maybe force a refresh/re-auth? Less common.
|
||||
else {
|
||||
addLog(
|
||||
'info',
|
||||
'Client not in a state requiring manual authentication trigger (state: ${state}). If needed, try disconnecting and reconnecting.',
|
||||
)
|
||||
}
|
||||
}, [state, addLog, retry, authUrl]) // Add authUrl
|
||||
}, [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 any displayed manual URL
|
||||
// Force disconnect and state reset as auth info is gone
|
||||
disconnect().then(() => {
|
||||
if (isMountedRef.current) {
|
||||
setState('discovering') // Ready for a fresh connect attempt
|
||||
}
|
||||
})
|
||||
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();
|
||||
} else {
|
||||
addLog('warn', 'Auth provider not initialized, cannot clear storage.')
|
||||
addLog('warn', 'Auth provider not initialized, cannot clear storage.');
|
||||
}
|
||||
}, [addLog, url, disconnect])
|
||||
}, [url, addLog, disconnect]); // Depends on url and stable callbacks
|
||||
|
||||
|
||||
// ===== Effects =====
|
||||
|
||||
// Effect for handling auth callback messages from popup
|
||||
// Effect for handling auth callback messages from popup (Stable dependencies)
|
||||
useEffect(() => {
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return
|
||||
|
||||
if (event.data && event.data.type === 'mcp_auth_callback') {
|
||||
addLog('info', 'Received auth callback message.', event.data)
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) // Stop timeout
|
||||
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);
|
||||
|
||||
if (event.data.success) {
|
||||
addLog('info', 'Authentication successful via popup. Reconnecting client...')
|
||||
// Tokens are saved by callback handler via provider.saveTokens.
|
||||
// Re-run connect() to establish the session with the new tokens.
|
||||
connectingRef.current = false // Allow connect again
|
||||
connect()
|
||||
addLog('info', 'Authentication successful via popup. Reconnecting client...');
|
||||
connectingRef.current = false;
|
||||
connect(); // Call stable connect
|
||||
} else {
|
||||
// Auth failed in the popup/callback handler
|
||||
failConnection(`Authentication failed in callback: ${event.data.error || 'Unknown reason.'}`)
|
||||
failConnection(`Authentication failed in callback: ${event.data.error || 'Unknown reason.'}`); // Call stable failConnection
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', messageHandler)
|
||||
addLog('debug', 'Auth callback message listener added.')
|
||||
};
|
||||
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)
|
||||
}
|
||||
}, [addLog, failConnection, connect]) // connect needed for success path
|
||||
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
|
||||
// Effect for initial connection (Stable dependencies)
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
addLog('debug', 'useMcp mounted, initiating connection.')
|
||||
connectAttemptRef.current = 0
|
||||
// Ensure provider exists before connecting
|
||||
if (!authProviderRef.current) {
|
||||
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 on mount.')
|
||||
storageKeyPrefix, clientName, clientUri, callbackUrl
|
||||
});
|
||||
addLog('debug', 'BrowserOAuthClientProvider initialized/updated on mount/option change.');
|
||||
}
|
||||
connect() // Initial connection attempt
|
||||
connect(); // Call stable connect
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
addLog('debug', 'useMcp unmounting, disconnecting.')
|
||||
disconnect(true) // Quiet disconnect on unmount
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
isMountedRef.current = false;
|
||||
addLog('debug', 'useMcp unmounting, disconnecting.');
|
||||
disconnect(true); // Call stable disconnect
|
||||
};
|
||||
// Ensure all config options that influence provider/connect are dependencies
|
||||
}, [
|
||||
url, // Reconnect if URL changes
|
||||
storageKeyPrefix,
|
||||
callbackUrl,
|
||||
clientName,
|
||||
clientUri, // Reconnect if provider config changes
|
||||
// connect, disconnect are stable due to useCallback
|
||||
])
|
||||
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
|
||||
]);
|
||||
|
||||
// Effect for auto-retry logic
|
||||
// Effect for auto-retry logic (Stable dependencies)
|
||||
useEffect(() => {
|
||||
let retryTimeoutId: number | null = null
|
||||
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(() => {
|
||||
if (isMountedRef.current && state === 'failed') {
|
||||
retry()
|
||||
// Check mount status and state again before retrying
|
||||
if (isMountedRef.current && stateRef.current === 'failed') {
|
||||
retry(); // Call stable retry
|
||||
}
|
||||
}, delay)
|
||||
}, delay);
|
||||
}
|
||||
return () => {
|
||||
if (retryTimeoutId) clearTimeout(retryTimeoutId)
|
||||
}
|
||||
}, [state, autoRetry, retry, addLog])
|
||||
if (retryTimeoutId) clearTimeout(retryTimeoutId);
|
||||
};
|
||||
// Depends on state (to trigger), autoRetry config, and stable retry/addLog callbacks
|
||||
}, [state, autoRetry, retry, addLog]);
|
||||
|
||||
|
||||
return {
|
||||
state,
|
||||
tools,
|
||||
error,
|
||||
log,
|
||||
authUrl, // Expose the manually retrieved auth URL
|
||||
authUrl,
|
||||
callTool,
|
||||
retry,
|
||||
disconnect,
|
||||
authenticate,
|
||||
clearStorage,
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue