GEMINI TO THE RESCUE

This commit is contained in:
Glen Maddern 2025-05-06 14:59:38 +10:00
commit d0372fd8d8
5 changed files with 573 additions and 757 deletions

View file

@ -1,25 +1,24 @@
import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; // browser-provider.ts
import { OAuthClientInformation, OAuthMetadata, OAuthTokens, OAuthClientMetadata } from '@modelcontextprotocol/sdk/shared/auth.js';
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
import { StoredState } from './types.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. * Browser-compatible OAuth client provider for MCP using localStorage.
* Handles storing client information, tokens, and managing the popup/redirect flow.
*/ */
export class BrowserOAuthClientProvider implements OAuthClientProvider { export class BrowserOAuthClientProvider implements OAuthClientProvider {
private storageKeyPrefix: string; readonly serverUrl: string;
private serverUrlHash: string; readonly storageKeyPrefix: string;
private clientName: string; readonly serverUrlHash: string;
private clientUri: string; readonly clientName: string;
private callbackUrl: string; readonly clientUri: string;
readonly callbackUrl: string;
/**
* Creates an instance of BrowserOAuthClientProvider.
* @param serverUrl The base URL of the MCP server requiring authentication.
* @param options Configuration options for the provider.
*/
constructor( constructor(
readonly serverUrl: string, serverUrl: string,
options: { options: {
storageKeyPrefix?: string; storageKeyPrefix?: string;
clientName?: string; clientName?: string;
@ -27,26 +26,21 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider {
callbackUrl?: string; callbackUrl?: string;
} = {}, } = {},
) { ) {
this.serverUrl = serverUrl;
this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth'; this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth';
// Hash the server URL to create unique storage keys per server
this.serverUrlHash = this.hashString(serverUrl); this.serverUrlHash = this.hashString(serverUrl);
this.clientName = options.clientName || 'MCP Browser Client'; this.clientName = options.clientName || 'MCP Browser Client';
this.clientUri = options.clientUri || window.location.origin; this.clientUri = options.clientUri || (typeof window !== 'undefined' ? window.location.origin : '');
// Default callback URL is /oauth/callback on the current origin this.callbackUrl = options.callbackUrl || (typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback');
this.callbackUrl = options.callbackUrl || new URL('/oauth/callback', window.location.origin).toString();
} }
/** // --- SDK Interface Methods ---
* The redirect URL used for the OAuth flow.
*/
get redirectUrl(): string { get redirectUrl(): string {
return this.callbackUrl; return this.callbackUrl;
} }
/** get clientMetadata(): OAuthClientMetadata {
* Metadata about this client, sent during dynamic client registration (if supported by the server).
*/
get clientMetadata() {
return { return {
redirect_uris: [this.redirectUrl], redirect_uris: [this.redirectUrl],
token_endpoint_auth_method: 'none', // Public client token_endpoint_auth_method: 'none', // Public client
@ -58,222 +52,180 @@ export class BrowserOAuthClientProvider implements OAuthClientProvider {
}; };
} }
/**
* Clears all localStorage items associated with this specific server URL.
* @returns The number of items removed from storage.
*/
clearStorage(): number {
const prefix = `${this.storageKeyPrefix}_${this.serverUrlHash}`;
const keysToRemove: string[] = [];
// Find keys directly related to this server instance
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(prefix)) {
keysToRemove.push(key);
}
}
// Also check any persisted OAuth state keys
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(`${this.storageKeyPrefix}:state_`)) {
try {
const item = localStorage.getItem(key);
if (item) {
const state = JSON.parse(item) as Partial<StoredState>;
// If state belongs to this server, mark for removal
if (state.serverUrlHash === this.serverUrlHash) {
keysToRemove.push(key);
}
}
} catch (e) {
console.warn(`[${this.storageKeyPrefix}] Error parsing state key ${key}:`, e);
// Optionally remove malformed keys
// keysToRemove.push(key);
}
}
}
// Remove all identified keys
const uniqueKeysToRemove = [...new Set(keysToRemove)]; // Ensure uniqueness
uniqueKeysToRemove.forEach(key => localStorage.removeItem(key));
return uniqueKeysToRemove.length;
}
private hashString(str: string): string {
// Simple, non-cryptographic hash function suitable for creating unique keys
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
// Use absolute value and convert to hex for a cleaner key
return Math.abs(hash).toString(16);
}
private getKey(key: string): string {
return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}`;
}
/**
* Retrieves client information (like client_id) from storage.
* @returns The stored client information, or undefined if not found/invalid.
*/
async clientInformation(): Promise<OAuthClientInformation | undefined> { async clientInformation(): Promise<OAuthClientInformation | undefined> {
const key = this.getKey('client_info'); const key = this.getKey('client_info');
const data = localStorage.getItem(key); const data = localStorage.getItem(key);
if (!data) return undefined; if (!data) return undefined;
try { try {
// TODO: Add validation using a schema (e.g., Zod) // TODO: Add validation using a schema
return JSON.parse(data) as OAuthClientInformation; return JSON.parse(data) as OAuthClientInformation;
} catch (e) { } catch (e) {
console.warn(`[${this.storageKeyPrefix}] Failed to parse client information from storage:`, e); console.warn(`[${this.storageKeyPrefix}] Failed to parse client information:`, e);
localStorage.removeItem(key); // Clean up invalid data localStorage.removeItem(key);
return undefined; return undefined;
} }
} }
/** // NOTE: The SDK's auth() function uses this if dynamic registration is needed.
* Saves client information to storage. // Ensure your OAuthClientInformationFull matches the expected structure if DCR is used.
* @param clientInformation The client information to save. async saveClientInformation(clientInformation: OAuthClientInformation /* | OAuthClientInformationFull */): Promise<void> {
*/
async saveClientInformation(clientInformation: OAuthClientInformation): Promise<void> {
const key = this.getKey('client_info'); const key = this.getKey('client_info');
// Cast needed if handling OAuthClientInformationFull specifically
localStorage.setItem(key, JSON.stringify(clientInformation)); localStorage.setItem(key, JSON.stringify(clientInformation));
} }
/**
* Retrieves OAuth tokens (access token, refresh token) from storage.
* @returns The stored tokens, or undefined if not found/invalid.
*/
async tokens(): Promise<OAuthTokens | undefined> { async tokens(): Promise<OAuthTokens | undefined> {
const key = this.getKey('tokens'); const key = this.getKey('tokens');
const data = localStorage.getItem(key); const data = localStorage.getItem(key);
if (!data) return undefined; if (!data) return undefined;
try { try {
// TODO: Add validation using a schema (e.g., Zod) // TODO: Add validation
const tokens = JSON.parse(data) as OAuthTokens; return JSON.parse(data) as OAuthTokens;
// Optional: Check token expiry here if 'expires_at' is stored
return tokens;
} catch (e) { } catch (e) {
console.warn(`[${this.storageKeyPrefix}] Failed to parse tokens from storage:`, e); console.warn(`[${this.storageKeyPrefix}] Failed to parse tokens:`, e);
localStorage.removeItem(key); // Clean up invalid data localStorage.removeItem(key);
return undefined; return undefined;
} }
} }
/**
* Saves OAuth tokens to storage.
* @param tokens The tokens to save.
*/
async saveTokens(tokens: OAuthTokens): Promise<void> { async saveTokens(tokens: OAuthTokens): Promise<void> {
const key = this.getKey('tokens'); const key = this.getKey('tokens');
// Optional: Calculate and store expiry timestamp:
// const storedTokens = { ...tokens, expires_at: Date.now() + (tokens.expires_in || 0) * 1000 };
localStorage.setItem(key, JSON.stringify(tokens)); localStorage.setItem(key, JSON.stringify(tokens));
// Clean up code verifier and last auth URL after successful token save
localStorage.removeItem(this.getKey('code_verifier'));
localStorage.removeItem(this.getKey('last_auth_url'));
} }
/**
* Initiates the OAuth authorization flow by opening a popup window.
* Stores necessary state in localStorage for the callback handler.
* @param authorizationUrl The fully constructed authorization URL (including client_id, redirect_uri, scope, code_challenge, etc.).
* @param metadata The discovered OAuth metadata of the server.
* @param options Configuration for the popup window.
* @returns An object indicating success, whether the popup was potentially blocked, and the URL used.
*/
// @ts-ignore
async redirectToAuthorization(
authorizationUrl: URL,
metadata: OAuthMetadata,
options?: {
popupFeatures?: string;
},
): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> {
// Generate a unique state parameter for this authorization request
const state = crypto.randomUUID(); // Use crypto.randomUUID for better randomness
const stateKey = `${this.storageKeyPrefix}:state_${state}`;
// Store context needed by the callback handler, associated with the state param
localStorage.setItem(
stateKey,
JSON.stringify({
authorizationUrl: authorizationUrl.origin, // Store origin for exchangeAuthorization
metadata,
serverUrlHash: this.serverUrlHash, // Link state back to this server instance
expiry: Date.now() + 1000 * 60 * 10, // State expires in 10 minutes
} as StoredState),
);
authorizationUrl.searchParams.set('state', state);
const authUrlString = authorizationUrl.toString();
const popupFeatures = options?.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes';
// Persist the exact auth URL in case the popup fails and manual navigation is needed
localStorage.setItem(this.getKey('auth_url'), authUrlString);
try {
// Attempt to open the authorization URL in a new window
const popup = window.open(authUrlString, `mcp_auth_${this.serverUrlHash}`, popupFeatures);
// Check if the popup window handle was obtained and if it's not immediately closed
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
// This is the most common indicator of a popup blocker
console.warn(`[${this.storageKeyPrefix}] Popup likely blocked by browser.`);
return { success: false, popupBlocked: true, url: authUrlString };
}
// Additional check: try accessing a property that might throw if blocked cross-origin
// Note: This might not always work due to browser security policies evolution
try {
// Accessing location.href will likely throw a cross-origin error immediately
// if the popup is loading the auth server's domain, which is expected.
// What we *really* want to detect is if the popup *failed to open at all*.
// The initial check (!popup || popup.closed) is usually sufficient.
// Focusing the popup can be a sign it opened successfully.
popup.focus();
} catch (e) {
// If accessing the popup threw an error immediately, it might indicate a deeper issue,
// but often it's just a standard cross-origin security restriction.
// The initial check is more reliable for detecting simple blocking.
console.debug(`[${this.storageKeyPrefix}] Accessing popup properties caused potential cross-origin error (normal):`, e);
}
// If we reached here, the popup handle was obtained and wasn't immediately closed.
return { success: true, url: authUrlString };
} catch (e) {
// Catch any unexpected errors during window.open
console.error(`[${this.storageKeyPrefix}] Error opening popup window:`, e);
return { success: false, popupBlocked: true, url: authUrlString }; // Assume blocked on error
}
}
/**
* Saves the PKCE code verifier to storage.
* @param codeVerifier The code verifier string.
*/
async saveCodeVerifier(codeVerifier: string): Promise<void> { async saveCodeVerifier(codeVerifier: string): Promise<void> {
const key = this.getKey('code_verifier'); const key = this.getKey('code_verifier');
localStorage.setItem(key, codeVerifier); localStorage.setItem(key, codeVerifier);
} }
/**
* Retrieves the PKCE code verifier from storage.
* @returns The stored code verifier.
* @throws If the code verifier is not found in storage.
*/
async codeVerifier(): Promise<string> { async codeVerifier(): Promise<string> {
const key = this.getKey('code_verifier'); const key = this.getKey('code_verifier');
const verifier = localStorage.getItem(key); const verifier = localStorage.getItem(key);
if (!verifier) { if (!verifier) {
throw new Error(`[${this.storageKeyPrefix}] Code verifier not found in storage for key ${key}. Auth flow likely corrupted or timed out.`); throw new Error(`[${this.storageKeyPrefix}] Code verifier not found in storage for key ${key}. Auth flow likely corrupted or timed out.`);
} }
// Optionally remove the verifier after retrieving it, as it's single-use // SDK's auth() retrieves this BEFORE exchanging code. Don't remove it here.
// localStorage.removeItem(key); // It will be removed in saveTokens on success.
return verifier; return verifier;
} }
}
/**
* Redirects the user agent to the authorization URL, storing necessary state.
* This now adheres to the SDK's void return type expectation for the interface.
* @param authorizationUrl The fully constructed authorization URL from the SDK.
*/
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
// Generate a unique state parameter for this authorization request
const state = crypto.randomUUID();
const stateKey = `${this.storageKeyPrefix}:state_${state}`;
// Store context needed by the callback handler, associated with the state param
const stateData: StoredState = {
serverUrlHash: this.serverUrlHash,
expiry: Date.now() + 1000 * 60 * 10, // State expires in 10 minutes
// Store provider options needed to reconstruct on callback
providerOptions: {
serverUrl: this.serverUrl,
storageKeyPrefix: this.storageKeyPrefix,
clientName: this.clientName,
clientUri: this.clientUri,
callbackUrl: this.callbackUrl,
}
};
localStorage.setItem(stateKey, JSON.stringify(stateData));
// Add the state parameter to the URL
authorizationUrl.searchParams.set('state', state);
const authUrlString = authorizationUrl.toString();
// Persist the exact auth URL in case the popup fails and manual navigation is needed
localStorage.setItem(this.getKey('last_auth_url'), authUrlString);
// Attempt to open the popup
const popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes'; // Make configurable if needed
try {
const popup = window.open(authUrlString, `mcp_auth_${this.serverUrlHash}`, popupFeatures);
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
console.warn(`[${this.storageKeyPrefix}] Popup likely blocked by browser. Manual navigation might be required using the stored URL.`);
// Cannot signal failure back via SDK auth() directly.
// useMcp will need to rely on timeout or manual trigger if stuck.
} else {
popup.focus();
console.info(`[${this.storageKeyPrefix}] Redirecting to authorization URL in popup.`);
}
} catch (e) {
console.error(`[${this.storageKeyPrefix}] Error opening popup window:`, e);
// Cannot signal failure back via SDK auth() directly.
}
// Regardless of popup success, the interface expects this method to initiate the redirect.
// If the popup failed, the user journey stops here until manual action or timeout.
}
// --- Helper Methods ---
/**
* Retrieves the last URL passed to `redirectToAuthorization`. Useful for manual fallback.
*/
getLastAttemptedAuthUrl(): string | null {
return localStorage.getItem(this.getKey('last_auth_url'));
}
clearStorage(): number {
const prefixPattern = `${this.storageKeyPrefix}_${this.serverUrlHash}_`;
const statePattern = `${this.storageKeyPrefix}:state_`;
const keysToRemove: string[] = [];
let count = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
if (key.startsWith(prefixPattern)) {
keysToRemove.push(key);
} else if (key.startsWith(statePattern)) {
try {
const item = localStorage.getItem(key);
if (item) {
// Check if state belongs to this provider instance based on serverUrlHash
// We need to parse cautiously as the structure isn't guaranteed.
const state = JSON.parse(item) as Partial<StoredState>;
if (state.serverUrlHash === this.serverUrlHash) {
keysToRemove.push(key);
}
}
} catch (e) {
console.warn(`[${this.storageKeyPrefix}] Error parsing state key ${key} during clearStorage:`, e);
// Optionally remove malformed keys
// keysToRemove.push(key);
}
}
}
const uniqueKeysToRemove = [...new Set(keysToRemove)];
uniqueKeysToRemove.forEach(key => {
localStorage.removeItem(key);
count++;
});
return count;
}
private hashString(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16);
}
getKey(keySuffix: string): string {
return `${this.storageKeyPrefix}_${this.serverUrlHash}_${keySuffix}`;
}
}

View file

@ -1,180 +1,137 @@
import { exchangeAuthorization } from '@modelcontextprotocol/sdk/client/auth.js'; // callback.ts
import { OAuthClientInformation, OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'; import { auth } from '@modelcontextprotocol/sdk/client/auth.js'
import { StoredState } from './types.js'; import { BrowserOAuthClientProvider } from './browser-provider.js' // Adjust path
import { StoredState } from './types.js' // Adjust path, ensure definition includes providerOptions
/** /**
* Handles the OAuth callback. This function should be invoked on your callback page * Handles the OAuth callback using the SDK's auth() function.
* (e.g., /oauth/callback) with the URL query parameters. * Assumes it's running on the page specified as the callbackUrl.
*
* It validates the state parameter, retrieves stored context from localStorage,
* exchanges the authorization code for tokens using the stored code verifier,
* saves the tokens, and notifies the opener window.
*
* @param query The URL query parameters (e.g., from `window.location.search`).
* @param options Configuration options.
* @param options.storageKeyPrefix The prefix used for localStorage keys (must match the one used by `BrowserOAuthClientProvider`). Defaults to "mcp:auth".
* @returns An object indicating success or failure with an error message.
*/ */
export async function onMcpAuthorization( export async function onMcpAuthorization() {
query: Record<string, string>, const queryParams = new URLSearchParams(window.location.search);
{ const code = queryParams.get('code');
storageKeyPrefix = 'mcp:auth', const state = queryParams.get('state');
}: { const error = queryParams.get('error');
storageKeyPrefix?: string; const errorDescription = queryParams.get('error_description');
} = {},
): Promise<{ success: boolean; error?: string }> { const logPrefix = '[mcp-callback]'; // Generic prefix, or derive from stored state later
const logPrefix = `[${storageKeyPrefix}-callback]`; console.log(`${logPrefix} Handling callback...`, { code, state, error, errorDescription });
console.log(`${logPrefix} Handling OAuth callback with query:`, query);
let provider: BrowserOAuthClientProvider | null = null;
let storedStateData: StoredState | null = null;
const stateKey = state ? `mcp:auth:state_${state}` : null; // Reconstruct state key prefix assumption
try { try {
const code = query.code; // --- Basic Error Handling ---
const state = query.state;
const error = query.error;
const errorDescription = query.error_description;
if (error) { if (error) {
throw new Error(`OAuth error: ${error} - ${errorDescription || 'No description provided.'}`); throw new Error(`OAuth error: ${error} - ${errorDescription || 'No description provided.'}`);
} }
if (!code) { if (!code) {
throw new Error('Authorization code not found in callback query parameters.'); throw new Error('Authorization code not found in callback query parameters.');
} }
if (!state) { if (!state || !stateKey) {
throw new Error('State parameter not found in callback query parameters.'); throw new Error('State parameter not found or invalid in callback query parameters.');
} }
// Retrieve the stored state using the state parameter from the query // --- Retrieve Stored State & Provider Options ---
const stateKey = `${storageKeyPrefix}:state_${state}`;
const storedStateJSON = localStorage.getItem(stateKey); const storedStateJSON = localStorage.getItem(stateKey);
if (!storedStateJSON) { if (!storedStateJSON) {
throw new Error(`Invalid or expired state parameter "${state}". No matching state found in storage.`); throw new Error(`Invalid or expired state parameter "${state}". No matching state found in storage.`);
} }
let storedState: StoredState;
try { try {
storedState = JSON.parse(storedStateJSON); storedStateData = JSON.parse(storedStateJSON) as StoredState;
} catch (e) { } catch (e) {
throw new Error('Failed to parse stored OAuth state.'); throw new Error('Failed to parse stored OAuth state.');
} }
// Validate expiry // Validate expiry
if (storedState.expiry < Date.now()) { if (!storedStateData.expiry || storedStateData.expiry < Date.now()) {
localStorage.removeItem(stateKey); // Clean up expired state
throw new Error('OAuth state has expired. Please try initiating authentication again.'); throw new Error('OAuth state has expired. Please try initiating authentication again.');
} }
const { authorizationUrl, serverUrlHash, metadata } = storedState; // Ensure provider options are present
console.log(`${logPrefix} Found valid state for server hash: ${serverUrlHash}`); if (!storedStateData.providerOptions) {
throw new Error('Stored state is missing required provider options.');
// Construct keys to retrieve client info and code verifier for this server instance
const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info`;
const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier`;
const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens`;
const authUrlKey = `${storageKeyPrefix}_${serverUrlHash}_auth_url`; // Key for the potentially stored manual auth URL
const clientInfoStr = localStorage.getItem(clientInfoKey);
const codeVerifier = localStorage.getItem(codeVerifierKey);
if (!clientInfoStr) {
throw new Error(`Client information not found in storage (key: ${clientInfoKey}).`);
}
if (!codeVerifier) {
throw new Error(`Code verifier not found in storage (key: ${codeVerifierKey}). Auth flow may be incomplete or timed out.`);
} }
const { serverUrl, ...providerOptions } = storedStateData.providerOptions;
let clientInfo: OAuthClientInformation;
try {
clientInfo = JSON.parse(clientInfoStr);
} catch (e) {
throw new Error('Failed to parse stored client information.');
}
console.log(`${logPrefix} Exchanging authorization code for token...`); // --- Instantiate Provider ---
console.log(`${logPrefix} Re-instantiating provider for server: ${serverUrl}`);
provider = new BrowserOAuthClientProvider(serverUrl, providerOptions);
// Exchange the code for tokens // --- Call SDK Auth Function ---
const tokens = await exchangeAuthorization(new URL(metadata.token_endpoint), { console.log(`${logPrefix} Calling SDK auth() to exchange code...`);
// Use token_endpoint from metadata // The SDK auth() function will internally:
metadata, // 1. Use provider.clientInformation()
clientInformation: clientInfo, // 2. Use provider.codeVerifier()
authorizationCode: code, // 3. Call exchangeAuthorization()
codeVerifier, // 4. Use provider.saveTokens() on success
// redirectUrl is typically required by the token endpoint for verification const authResult = await auth(provider, { serverUrl, authorizationCode: code });
// @ts-ignore
redirectUrl: new URL('/oauth/callback', window.location.origin).toString(), // TODO: Make this configurable or get from provider instance?
});
console.log(`${logPrefix} Token exchange successful. Saving tokens...`); if (authResult === 'AUTHORIZED') {
console.log(`${logPrefix} Authorization successful via SDK auth(). Notifying opener...`);
// --- Notify Opener and Close (Success) ---
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{ type: 'mcp_auth_callback', success: true },
window.location.origin,
);
window.close();
} else {
console.warn(`${logPrefix} No opener window detected. Redirecting to root.`);
window.location.href = '/'; // Or a configured post-auth destination
}
// Clean up state ONLY on success and after notifying opener
localStorage.removeItem(stateKey);
// Save the obtained tokens
localStorage.setItem(tokensKey, JSON.stringify(tokens));
// Clean up the persisted manual auth URL if it exists
localStorage.removeItem(authUrlKey);
// Remove the state key immediately after retrieval (it's single-use)
localStorage.removeItem(stateKey);
// Remove the code verifier after retrieving it
localStorage.removeItem(codeVerifierKey);
console.log(`${logPrefix} Tokens saved. Notifying opener window.`);
// Notify the original window (opener) that authentication succeeded
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{
type: 'mcp_auth_callback',
success: true,
// Optionally pass serverUrlHash if multiple hooks might be listening
serverUrlHash: serverUrlHash,
},
window.location.origin, // Target origin must match the opener's origin
);
// Close the popup window
window.close();
} else { } else {
// If there's no opener, this might be a full page redirect flow. // This case shouldn't happen if `authorizationCode` is provided to `auth()`
// Redirect back to the app's main page or a specific post-auth page. console.warn(`${logPrefix} SDK auth() returned unexpected status: ${authResult}`);
console.warn(`${logPrefix} No opener window detected. Assuming redirect flow. Redirecting to root.`); throw new Error(`Unexpected result from authentication library: ${authResult}`);
window.location.href = '/'; // Adjust as needed
} }
return { success: true };
} catch (err) { } catch (err) {
console.error(`${logPrefix} Error during OAuth callback handling:`, err); console.error(`${logPrefix} Error during OAuth callback handling:`, err);
const errorMessage = err instanceof Error ? err.message : String(err); const errorMessage = err instanceof Error ? err.message : String(err);
// Attempt to notify the opener window about the failure // --- Notify Opener and Display Error (Failure) ---
if (window.opener && !window.opener.closed) { if (window.opener && !window.opener.closed) {
window.opener.postMessage( window.opener.postMessage(
{ { type: 'mcp_auth_callback', success: false, error: errorMessage },
type: 'mcp_auth_callback',
success: false,
error: errorMessage,
// Optionally pass serverUrlHash
// serverUrlHash: storedState?.serverUrlHash // Might not be available if state parsing failed
},
window.location.origin, window.location.origin,
); );
// Optionally close even on error, depending on UX preference
// window.close();
} }
// Display error in the callback window itself for better debugging // Display error in the callback window
try { try {
document.body.innerHTML = ` document.body.innerHTML = `
<div style="font-family: sans-serif; padding: 20px;"> <div style="font-family: sans-serif; padding: 20px;">
<h1>Authentication Error</h1> <h1>Authentication Error</h1>
<p style="color: red; background-color: #ffebeb; border: 1px solid red; padding: 10px; border-radius: 4px;"> <p style="color: red; background-color: #ffebeb; border: 1px solid red; padding: 10px; border-radius: 4px;">
${errorMessage} ${errorMessage}
</p> </p>
<p>You can close this window.</p> <p>You can close this window or <a href="#" onclick="window.close(); return false;">click here to close</a>.</p>
<pre style="font-size: 0.8em; color: #555; margin-top: 20px; white-space: pre-wrap;">${ <pre style="font-size: 0.8em; color: #555; margin-top: 20px; white-space: pre-wrap;">${
err instanceof Error ? err.stack : '' err instanceof Error ? err.stack : ''
}</pre> }</pre>
</div> </div>
`; `;
} catch (displayError) { } catch (displayError) {
// Fallback if document.body is not available or writable
console.error(`${logPrefix} Could not display error in callback window:`, displayError); console.error(`${logPrefix} Could not display error in callback window:`, displayError);
} }
// Clean up potentially invalid state on error
return { success: false, error: errorMessage }; if (stateKey) {
localStorage.removeItem(stateKey);
}
// Clean up potentially dangling verifier or last_auth_url if auth failed badly
// Note: saveTokens should clean these on success
if (provider) {
localStorage.removeItem(provider.getKey('code_verifier'));
localStorage.removeItem(provider.getKey('last_auth_url'));
}
} }
} }

View file

@ -4,9 +4,16 @@ import { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js';
* Internal type for storing OAuth state in localStorage during the popup flow. * Internal type for storing OAuth state in localStorage during the popup flow.
* @internal * @internal
*/ */
export type StoredState = { export interface StoredState {
authorizationUrl: string; expiry: number;
metadata: OAuthMetadata; metadata?: OAuthMetadata; // Optional: might not be needed if auth() rediscovers
serverUrlHash: string; // To associate state with a specific server URL serverUrlHash: string;
expiry: number; // Timestamp when the state expires // Add provider options needed on callback:
}; providerOptions: {
serverUrl: string;
storageKeyPrefix: string;
clientName: string;
clientUri: string;
callbackUrl: string;
};
}

View file

@ -66,7 +66,7 @@ export type UseMcpResult = {
* @returns A promise that resolves with the authorization URL opened (or intended to be opened), * @returns A promise that resolves with the authorization URL opened (or intended to be opened),
* or undefined if auth cannot be started. * or undefined if auth cannot be started.
*/ */
authenticate: () => Promise<string | undefined>; authenticate: () => void;
/** Clears all stored authentication data (tokens, client info, etc.) for this server URL from localStorage. */ /** Clears all stored authentication data (tokens, client info, etc.) for this server URL from localStorage. */
clearStorage: () => void; clearStorage: () => void;
}; };

View file

@ -1,575 +1,475 @@
import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js'; // useMcp.ts
import { useCallback, useEffect, useRef, useState } from 'react'; import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'; import { useCallback, useEffect, useRef, useState } from 'react'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
import { discoverOAuthMetadata, exchangeAuthorization, startAuthorization, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; // Import the main auth function and error type
import { BrowserOAuthClientProvider } from '../auth/browser-provider.js'; import { auth, UnauthorizedError, OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
import { assert } from '../utils/assert.js'; // Keep BrowserOAuthClientProvider
import type { UseMcpOptions, UseMcpResult } from './types.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_RECONNECT_DELAY = 3000
const DEFAULT_RETRY_DELAY = 5000; const DEFAULT_RETRY_DELAY = 5000
const AUTH_TIMEOUT = 5 * 60 * 1000; // 5 minutes for user to complete auth in popup const AUTH_TIMEOUT = 5 * 60 * 1000 // 5 minutes for user to complete auth in popup
/**
* React hook to connect to a remote MCP server, handle OAuth authentication,
* and provide access to server-side tools for use with AI SDKs.
*
* @param options Configuration options for connecting to the MCP server.
* @returns State and functions for interacting with the MCP connection.
*/
export function useMcp(options: UseMcpOptions): UseMcpResult { export function useMcp(options: UseMcpOptions): UseMcpResult {
const { const {
url, url,
clientName, // Passed to BrowserOAuthClientProvider clientName,
clientUri, // Passed to BrowserOAuthClientProvider clientUri,
callbackUrl = typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback', callbackUrl = typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback',
storageKeyPrefix = 'mcp:auth', storageKeyPrefix = 'mcp:auth',
clientConfig = {}, clientConfig = {},
debug = false, debug = false,
autoRetry = false, autoRetry = false,
autoReconnect = DEFAULT_RECONNECT_DELAY, autoReconnect = DEFAULT_RECONNECT_DELAY,
popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes', // popupFeatures is now handled within BrowserOAuthClientProvider
} = options; } = options
const [state, setState] = useState<UseMcpResult['state']>('discovering'); const [state, setState] = useState<UseMcpResult['state']>('discovering')
const [tools, setTools] = useState<Tool[]>([]); const [tools, setTools] = useState<Tool[]>([])
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined)
const [log, setLog] = useState<UseMcpResult['log']>([]); const [log, setLog] = useState<UseMcpResult['log']>([])
const [authUrl, setAuthUrl] = useState<string | undefined>(undefined); // For manual auth if popup blocked // Store the URL for manual fallback if needed
const [authUrl, setAuthUrl] = useState<string | undefined>(undefined)
const clientRef = useRef<Client | null>(null); const clientRef = useRef<Client | null>(null)
const transportRef = useRef<SSEClientTransport | null>(null); const transportRef = useRef<SSEClientTransport | null>(null)
const authProviderRef = useRef<BrowserOAuthClientProvider | null>(null); const authProviderRef = useRef<BrowserOAuthClientProvider | null>(null)
const metadataRef = useRef<OAuthMetadata | undefined>(undefined); const connectingRef = useRef<boolean>(false)
const pendingAuthUrlRef = useRef<URL | undefined>(undefined); // Stores the URL while waiting for popup const isMountedRef = useRef<boolean>(true)
const connectingRef = useRef<boolean>(false); // Prevent concurrent connection attempts const connectAttemptRef = useRef<number>(0)
const isMountedRef = useRef<boolean>(true); // Track component mount status const authTimeoutRef = useRef<number | null>(null) // Use number for type safety
const connectAttemptRef = useRef<number>(0); // Track connection attempts for retry logic
const authTimeoutRef = useRef<number | null>(null); // Timer for auth popup
// ===== Logging Utility =====
const addLog = useCallback( const addLog = useCallback(
(level: UseMcpResult['log'][0]['level'], message: string, ...args: unknown[]) => { (level: UseMcpResult['log'][0]['level'], message: string, ...args: unknown[]) => {
// if (level === 'debug' && !debug) return; 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}`);
if (isMountedRef.current) { if (isMountedRef.current) {
setLog((prevLog) => [...prevLog, { level, message: fullMessage, timestamp: Date.now() }]); setLog((prevLog) => [...prevLog.slice(-100), { level, message: fullMessage, timestamp: Date.now() }]) // Limit log size
} }
}, },
[debug], // Remove isMountedRef as it's a ref and shouldn't be a dependency [], // No dependencies needed if `debug` isn't used inside
); )
// ===== Core Actions =====
const disconnect = useCallback( const disconnect = useCallback(
async (quiet = false) => { async (quiet = false) => {
if (!quiet) addLog('info', 'Disconnecting...'); if (!quiet) addLog('info', 'Disconnecting...')
connectingRef.current = false; connectingRef.current = false
clearTimeout(authTimeoutRef.current!); if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
authTimeoutRef.current = null; authTimeoutRef.current = null
const client = clientRef.current; const client = clientRef.current
const transport = transportRef.current; const transport = transportRef.current
clientRef.current = null; clientRef.current = null
transportRef.current = null; transportRef.current = null // Prevent reconnection logic on explicit disconnect
// Don't reset state if unmounting
if (isMountedRef.current && !quiet) { if (isMountedRef.current && !quiet) {
setState('discovering'); // Or 'disconnected'? Let's stick to initial flow for retry. setState('discovering')
setTools([]); setTools([])
setError(undefined); setError(undefined)
setAuthUrl(undefined); setAuthUrl(undefined)
pendingAuthUrlRef.current = undefined;
} }
// Close transport first, then client
if (transport) { if (transport) {
try { try {
await transport.close(); await transport.close()
if (!quiet) addLog('debug', 'Transport closed'); if (!quiet) addLog('debug', 'Transport closed')
} catch (err) { } catch (err) {
if (!quiet) addLog('warn', 'Error closing transport:', err); if (!quiet) addLog('warn', 'Error closing transport:', err)
} }
} }
// Note: Client doesn't have an explicit async close, it relies on transport.
}, },
[addLog], [addLog],
); )
const failConnection = useCallback((errorMessage: string, connectionError?: Error) => { const failConnection = useCallback(
addLog('error', errorMessage, connectionError ?? ''); (errorMessage: string, connectionError?: Error) => {
if (isMountedRef.current) { addLog('error', errorMessage, connectionError ?? '')
setState('failed'); if (isMountedRef.current) {
setError(errorMessage); setState('failed')
} setError(errorMessage)
connectingRef.current = false; // Attempt to get manual auth URL if provider exists
disconnect(true); // Quiet disconnect const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl()
}, [addLog, disconnect]); if (manualUrl) {
setAuthUrl(manualUrl)
addLog('info', 'Manual authentication URL may be available.', manualUrl)
// ===== Authentication Flow =====
const initiateAuthFlow = useCallback(async (): Promise<URL | undefined> => {
assert(authProviderRef.current, 'Auth provider not initialized');
assert(metadataRef.current, 'OAuth metadata not discovered');
addLog('info', 'Initiating OAuth authorization flow...');
try {
// Dynamic Client Registration (DCR) - Placeholder
// In a real browser scenario, DCR is complex. Often, a pre-registered
// client_id is used. If DCR is needed, it typically involves a backend proxy.
let clientInfo = await authProviderRef.current.clientInformation();
if (!clientInfo) {
// Assuming pre-registration or manual setup for browser clients
// If DCR were implemented:
// addLog('info', 'No client info found, attempting dynamic registration...');
// clientInfo = await registerClient(url, authProviderRef.current.clientMetadata);
// await authProviderRef.current.saveClientInformation(clientInfo);
// addLog('info', 'Client registration successful');
// For now, error if no client_id is found (must be pre-configured or manually stored)
addLog('warn', 'Client information not found in storage. Authentication requires a pre-registered client_id.');
throw new Error('Client information (client_id) not found. Please ensure the client is registered with the auth server and the details are stored, or implement dynamic registration.');
}
addLog('debug', 'Using client information:', clientInfo);
const { authorizationUrl, codeVerifier } = await startAuthorization(url, {
metadata: metadataRef.current,
clientInformation: clientInfo,
redirectUrl: authProviderRef.current.redirectUrl,
// scopes: ['openid', 'profile', 'mcp'], // Optional: Request specific scopes
});
addLog('debug', 'Authorization URL created:', authorizationUrl.toString());
await authProviderRef.current.saveCodeVerifier(codeVerifier);
addLog('debug', 'Code verifier saved.');
pendingAuthUrlRef.current = authorizationUrl; // Store for popup opening
setAuthUrl(authorizationUrl.toString()); // Expose for manual fallback
return authorizationUrl;
} catch (err) {
failConnection(`Failed to start OAuth authorization: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : undefined);
return undefined;
}
}, [url, addLog, failConnection]);
const openAuthPopup = useCallback(async () => {
assert(authProviderRef.current, 'Auth provider not available');
assert(metadataRef.current, 'Metadata not available');
let authPopupUrl = pendingAuthUrlRef.current;
// If URL isn't already generated, try generating it now
if (!authPopupUrl) {
addLog('info', 'Auth URL not pre-generated, initiating flow now...');
authPopupUrl = await initiateAuthFlow();
if (!authPopupUrl) {
// initiateAuthFlow calls failConnection on error
return undefined;
}
}
addLog('info', 'Attempting to open authentication popup...');
setState('authenticating'); // Update state *before* opening popup
const redirectResult = await authProviderRef.current.redirectToAuthorization(
authPopupUrl,
metadataRef.current,
{ popupFeatures },
);
if (redirectResult.success) {
addLog('info', 'Authentication popup opened. Waiting for user action...');
// Start timeout for auth completion
clearTimeout(authTimeoutRef.current!);
authTimeoutRef.current = setTimeout(() => {
addLog('warn', 'Authentication timed out.');
if (isMountedRef.current && state === 'authenticating') {
failConnection('Authentication timed out. Please try again.');
} }
}, AUTH_TIMEOUT); }
} else { connectingRef.current = false
addLog('warn', 'Authentication popup was blocked or failed to open.'); // Don't disconnect here, allow user to potentially retry/authenticate
setAuthUrl(redirectResult.url); // Ensure the manual URL is available // disconnect(true); // No longer needed, state is 'failed'
failConnection( },
'Authentication popup blocked. Please allow popups for this site or use the provided link to authenticate manually.', [addLog],
new Error('PopupBlocked') // Custom error? )
);
// Do not clear pendingAuthUrlRef here, retry might need it
}
return redirectResult.url;
}, [addLog, failConnection, initiateAuthFlow, popupFeatures, state]);
// ===== Connection Logic =====
const connect = useCallback(async () => { const connect = useCallback(async () => {
if (connectingRef.current) { if (connectingRef.current) {
addLog('debug', 'Connection attempt already in progress, skipping.'); addLog('debug', 'Connection attempt already in progress.')
return; return
} }
if (!isMountedRef.current) return; // Don't connect if unmounted if (!isMountedRef.current) return
connectingRef.current = true; connectingRef.current = true
connectAttemptRef.current += 1; connectAttemptRef.current += 1
setError(undefined); setError(undefined)
setAuthUrl(undefined); setAuthUrl(undefined) // Clear previous manual URL
pendingAuthUrlRef.current = undefined; setState('discovering')
setState('discovering'); addLog('info', `Connecting attempt #${connectAttemptRef.current} to ${url}...`)
addLog('info', `Connecting attempt #${connectAttemptRef.current}...`);
// 1. Initialize Auth Provider // 1. Initialize Auth Provider (only once)
if (!authProviderRef.current) { if (!authProviderRef.current) {
authProviderRef.current = new BrowserOAuthClientProvider(url, { authProviderRef.current = new BrowserOAuthClientProvider(url, {
storageKeyPrefix, storageKeyPrefix,
clientName, clientName,
clientUri, clientUri,
callbackUrl, callbackUrl,
}); })
addLog('debug', 'BrowserOAuthClientProvider initialized.'); addLog('debug', 'BrowserOAuthClientProvider initialized.')
} }
// 2. Initialize MCP Client // 2. Initialize MCP Client (only once)
if (!clientRef.current) { if (!clientRef.current) {
clientRef.current = new Client( clientRef.current = new Client(
{ {
name: clientConfig.name || 'use-mcp-react-client', name: clientConfig.name || 'use-mcp-react-client',
version: clientConfig.version || '0.1.0', // TODO: Get version from package.json? version: clientConfig.version || '0.1.0',
}, },
{ capabilities: { /* Define client caps if any */ } }, { capabilities: {} },
); )
addLog('debug', 'MCP Client initialized.'); addLog('debug', 'MCP Client initialized.')
} }
// 3. Create SSE Transport // 3. Create/Recreate SSE Transport
addLog('info', 'Creating SSE transport...'); setState('connecting')
setState('connecting');
const serverUrl = new URL(url); // Ensure it's a URL object
// Clear previous transport if any
if (transportRef.current) { if (transportRef.current) {
await transportRef.current.close().catch(e => addLog('warn', 'Error closing previous transport:', e)); await transportRef.current.close().catch((e) => addLog('warn', 'Error closing previous transport:', e))
transportRef.current = null
}
try {
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
} }
transportRef.current = new SSEClientTransport(serverUrl, {
// @ts-ignore
authProvider: authProviderRef.current,
// Pass debug flag to transport if it supports it
});
addLog('debug', 'SSEClientTransport created.');
// 4. Set up Transport Handlers // 4. Set up Transport Handlers
transportRef.current.onmessage = (message: JSONRPCMessage) => { transportRef.current.onmessage = (message: JSONRPCMessage) => {
// @ts-expect-error - SDK type might be union, check message content addLog('debug', `[Transport] Received: ${JSON.stringify(message)}`)
addLog('debug', `[Transport] Received message: Method=${message.method || 'N/A'} ID=${message.id || 'N/A'}`);
// Forward notifications/responses to the client instance
// @ts-ignore // @ts-ignore
clientRef.current?.handleMessage(message); clientRef.current?.handleMessage(message)
}; }
transportRef.current.onerror = (err: Error) => { transportRef.current.onerror = (err: Error) => {
// Check if the error indicates an auth issue *before* full connection // Transport errors usually mean connection is lost/failed
if (err instanceof UnauthorizedError || err.message.includes('Unauthorized') || err.message.includes('401')) { failConnection(`Transport error: ${err.message}`, err)
addLog('warn', 'Transport connection requires authentication.'); }
// Don't immediately fail, proceed to connect step which handles auth discovery
} else {
// Handle other transport-level errors (network issues, etc.)
failConnection(`Transport error: ${err.message}`, err);
}
};
transportRef.current.onclose = () => { transportRef.current.onclose = () => {
// Only handle close if we weren't explicitly disconnecting or failing if (!isMountedRef.current || connectingRef.current) return // Ignore if unmounting or mid-connect
if (connectingRef.current || !isMountedRef.current) return;
addLog('info', 'Transport connection closed.'); addLog('info', 'Transport connection closed.')
// Use the current state directly from the state variable const currentState = state // Capture state at time of closure
const currentState = state;
if (currentState === 'ready' && autoReconnect) { if (currentState === 'ready' && autoReconnect) {
const delay = typeof autoReconnect === 'number' ? autoReconnect : DEFAULT_RECONNECT_DELAY; const delay = typeof autoReconnect === 'number' ? autoReconnect : DEFAULT_RECONNECT_DELAY
addLog('info', `Attempting to reconnect in ${delay}ms...`); addLog('info', `Attempting to reconnect in ${delay}ms...`)
setTimeout(() => { setTimeout(() => {
// Perform a clean disconnect before reconnecting if (isMountedRef.current && clientRef.current /* Check if still relevant */) {
disconnect().then(() => { connect() // Re-run the connection logic
if (isMountedRef.current) connect();
});
}, delay);
// Set state to indicate reconnection attempt? Maybe 'connecting'?
if (isMountedRef.current) setState('connecting');
} else if (currentState !== 'failed') {
// If not ready or autoReconnect is off, treat unexpected close as failure
failConnection('Connection closed unexpectedly.');
}
};
// 5. Connect Client (which starts the transport internally)
try {
addLog('info', 'Connecting client...');
assert(clientRef.current, "Client not initialized");
assert(transportRef.current, "Transport not initialized");
// The client.connect() method internally calls transport.start()
await clientRef.current.connect(transportRef.current);
// If connect resolves without error, we are connected (potentially anonymously)
addLog('info', 'Client connected successfully.');
setState('loading'); // Move to loading state
// 6. Load Tools
try {
addLog('info', 'Loading tools...');
const toolsResponse = await clientRef.current.request({ method: 'tools/list' }, ListToolsResultSchema);
if (isMountedRef.current) {
setTools(toolsResponse.tools);
addLog('info', `Loaded ${toolsResponse.tools.length} tools.`);
setState('ready'); // Final success state
connectingRef.current = false;
connectAttemptRef.current = 0; // Reset attempt counter on success
}
} catch (toolErr) {
// If loading tools fails, we are still connected but log the error.
addLog('error', `Failed to load tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`);
// Consider if this should be a 'failed' state or just 'ready' with no tools
if (isMountedRef.current) {
setState('ready'); // Stay ready, but tools array is empty/stale
setTools([]); // Ensure tools are empty on error
}
connectingRef.current = false; // Still finish connection attempt
}
} catch (connectErr) {
// Handle errors during the client.connect() phase
addLog('debug', 'Client connect error:', connectErr);
if (connectErr instanceof UnauthorizedError || (connectErr instanceof Error && (connectErr.message.includes('Unauthorized') || connectErr.message.includes('401')))) {
addLog('info', 'Authentication required.');
// Discover OAuth metadata
if (!metadataRef.current) {
try {
addLog('info', 'Discovering OAuth metadata...');
metadataRef.current = await discoverOAuthMetadata(url);
assert(metadataRef.current, 'Server does not provide OAuth metadata.');
addLog('info', 'OAuth metadata discovered:', metadataRef.current);
} catch (discoverErr) {
failConnection(`Failed to discover OAuth metadata: ${discoverErr instanceof Error ? discoverErr.message : String(discoverErr)}`, discoverErr instanceof Error ? discoverErr : undefined);
return; // Stop connection process
} }
} }, delay)
setState('connecting')
// We have metadata, attempt to open the popup } else if (currentState !== 'failed' && currentState !== 'authenticating') {
await openAuthPopup(); // This sets state to 'authenticating' or 'failed' // If closed unexpectedly and not already failed/authenticating
failConnection('Connection closed unexpectedly.')
} else {
// Handle other connection errors (network, server unavailable, etc.)
failConnection(`Failed to connect: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`, connectErr instanceof Error ? connectErr : undefined);
} }
} }
// Note: connectingRef is set to false within success/failure paths above or in openAuthPopup's failConnection.
// 5. Connect Client & Handle Auth
try {
addLog('info', 'Connecting client...')
assert(clientRef.current, 'Client not initialized')
assert(transportRef.current, 'Transport not initialized')
await clientRef.current.connect(transportRef.current)
addLog('info', 'Client connected. Loading tools...')
setState('loading')
const toolsResponse = await clientRef.current.request({ method: 'tools/list' }, ListToolsResultSchema)
if (isMountedRef.current) {
setTools(toolsResponse.tools)
addLog('info', `Loaded ${toolsResponse.tools.length} tools.`)
setState('ready')
connectingRef.current = false
connectAttemptRef.current = 0 // Reset on success
}
} catch (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)
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.')
}
}, 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.
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.
}
} 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,
)
}
} 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
}
}
}, [ }, [
url, storageKeyPrefix, clientName, clientUri, callbackUrl, url,
addLog, failConnection, disconnect, openAuthPopup, storageKeyPrefix,
// Note: state and autoReconnect are accessed but shouldn't trigger recreation of connect clientName,
// as they're only used conditionally inside inner functions clientUri,
]); callbackUrl,
clientConfig, // Provider/Client configs
addLog,
// ===== Public API Methods ===== failConnection,
disconnect, // Core actions
state,
autoReconnect, // State/Config used in handlers
])
const callTool = useCallback( const callTool = useCallback(
async (name: string, args?: Record<string, unknown>) => { async (name: string, args?: Record<string, unknown>) => {
if (state !== 'ready' || !clientRef.current) { if (state !== '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 (state: ${state}). Cannot call tool "${name}".`)
} }
addLog('info', `Calling tool: ${name}`, args); addLog('info', `Calling tool: ${name}`, args)
try { try {
const result = await clientRef.current.request( const result = await clientRef.current.request({ method: 'tools/call', params: { name, arguments: args } }, CallToolResultSchema)
{ addLog('info', `Tool "${name}" call successful:`, result)
method: 'tools/call', return result
params: { name, arguments: args },
},
CallToolResultSchema, // Optional: Validate result against schema
);
addLog('info', `Tool "${name}" call successful.`);
return result; // Assuming CallToolResultSchema has a `result` field
} catch (err) { } catch (err) {
addLog('error', `Error calling tool "${name}": ${err instanceof Error ? err.message : String(err)}`); addLog('error', `Error calling tool "${name}": ${err instanceof Error ? err.message : String(err)}`, err)
// TODO: Should specific JSON-RPC errors be handled differently? // TODO: Check if error is UnauthorizedError and trigger re-authentication?
throw err; // Re-throw the error 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,
)
})
}
throw err
} }
}, },
[state, addLog], [state, addLog, url], // Add url dependency for re-auth attempt
); )
const retry = useCallback(() => { const retry = useCallback(() => {
// Retry should attempt to connect again, regardless of the specific failure reason
if (state === 'failed') { if (state === 'failed') {
addLog('info', 'Manual retry requested...'); addLog('info', 'Retry requested...')
// Reset attempt counter? Or let connect handle it? Let connect handle it. // Reset state and attempt connection
disconnect().then(() => { setState('discovering')
if (isMountedRef.current) connect(); setError(undefined)
}); setAuthUrl(undefined)
connect()
} else { } else {
addLog('warn', `Retry called but state is not 'failed' (state: ${state}). Ignoring.`); addLog('warn', `Retry called but state is not 'failed' (state: ${state}). Ignoring.`)
} }
}, [state, addLog, disconnect, connect]); }, [state, addLog, connect])
const authenticate = useCallback(async (): Promise<string | undefined> => { const authenticate = useCallback(() => {
addLog('info', 'Manual authentication requested...'); addLog('info', 'Manual authentication requested...')
if (state === 'failed' && error?.includes('popup blocked')) { // Option 1: If failed, just retry connection which includes auth logic
// If we failed due to popup blocking, try opening the popup again if (state === 'failed') {
return openAuthPopup(); addLog('info', 'Attempting to reconnect and authenticate...')
} else if (!metadataRef.current && (state === 'failed' || state === 'discovering')) { retry() // Retry will call connect()
// If we failed before discovering metadata, try full connect again
addLog('info', 'Metadata not yet discovered, attempting full reconnect...');
await disconnect();
if (isMountedRef.current) await connect(); // connect will handle auth discovery
// We don't know the auth URL yet in this case
return undefined;
} else if (metadataRef.current) {
// If we have metadata, try opening popup directly
return openAuthPopup();
} else {
addLog('warn', 'Cannot manually authenticate in current state:', state);
return undefined;
} }
}, [state, error, addLog, openAuthPopup, disconnect, 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)
}
// Maybe try calling SDK auth again? Could be risky if popup is open.
// assert(authProviderRef.current, "Auth Provider not available for manual auth trigger");
// 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
const clearStorage = useCallback(() => { const clearStorage = useCallback(() => {
if (authProviderRef.current) { if (authProviderRef.current) {
const count = authProviderRef.current.clearStorage(); const count = authProviderRef.current.clearStorage()
addLog('info', `Cleared ${count} item(s) from localStorage for ${url}.`); addLog('info', `Cleared ${count} item(s) from localStorage for ${url}.`)
// Also clear related state in the hook setAuthUrl(undefined) // Clear any displayed manual URL
metadataRef.current = undefined; // Force disconnect and state reset as auth info is gone
pendingAuthUrlRef.current = undefined; disconnect().then(() => {
setAuthUrl(undefined); if (isMountedRef.current) {
// Consider if a disconnect/reconnect is needed after clearing storage setState('discovering') // Ready for a fresh connect attempt
addLog('info', 'Storage cleared. Recommend disconnecting and reconnecting if currently connected.'); }
disconnect(); // Force disconnect as auth state is gone })
} else { } else {
addLog('warn', 'Auth provider not initialized, cannot clear storage.'); addLog('warn', 'Auth provider not initialized, cannot clear storage.')
// Attempt to clear based on prefix/hash anyway? Risky.
// For safety, only clear if provider exists.
} }
}, [addLog, url, disconnect]); }, [addLog, url, disconnect])
// ===== Effects ===== // ===== Effects =====
// Effect for handling auth callback messages from popup // Effect for handling auth callback messages from popup
useEffect(() => { useEffect(() => {
const messageHandler = (event: MessageEvent) => { const messageHandler = (event: MessageEvent) => {
// Security: Check origin if (event.origin !== window.location.origin) return
if (event.origin !== window.location.origin) {
addLog('debug', `Ignoring message from different origin: ${event.origin}`);
return;
}
if (event.data && event.data.type === 'mcp_auth_callback') { if (event.data && event.data.type === 'mcp_auth_callback') {
addLog('info', 'Received auth callback message from popup window.', event.data); addLog('info', 'Received auth callback message.', event.data)
clearTimeout(authTimeoutRef.current!); // Stop the auth timeout if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) // Stop timeout
// Check if the message corresponds to this hook instance if multiple hooks might exist
// (Requires serverUrlHash to be passed back in message data from callback handler)
// const currentHash = authProviderRef.current?.serverUrlHash; // Need to expose serverUrlHash or compare URL
// if (currentHash && event.data.serverUrlHash && currentHash !== event.data.serverUrlHash) {
// addLog('debug', 'Ignoring auth callback for different server instance.');
// return;
// }
if (event.data.success) { if (event.data.success) {
addLog('info', 'Authentication successful via popup. Reconnecting...'); addLog('info', 'Authentication successful via popup. Reconnecting client...')
// The callback saved the token. Disconnect and reconnect to use it. // Tokens are saved by callback handler via provider.saveTokens.
disconnect().then(() => { // Re-run connect() to establish the session with the new tokens.
if (isMountedRef.current) connect(); connectingRef.current = false // Allow connect again
}); connect()
} else { } else {
// Auth failed in the popup/callback handler // Auth failed in the popup/callback handler
failConnection(`Authentication failed: ${event.data.error || 'Unknown reason.'}`); failConnection(`Authentication failed in callback: ${event.data.error || 'Unknown reason.'}`)
} }
} }
}; }
window.addEventListener('message', messageHandler);
addLog('debug', 'Auth callback message listener added.');
window.addEventListener('message', messageHandler)
addLog('debug', 'Auth callback message listener added.')
return () => { return () => {
window.removeEventListener('message', messageHandler); window.removeEventListener('message', messageHandler)
addLog('debug', 'Auth callback message listener removed.'); addLog('debug', 'Auth callback message listener removed.')
clearTimeout(authTimeoutRef.current!); if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
}; }
}, [addLog, failConnection, disconnect, connect]); // Dependencies: functions that handle success/failure }, [addLog, failConnection, connect]) // connect needed for success path
// Effect for initial connection and auto-retry // Effect for initial connection
useEffect(() => { useEffect(() => {
// Store mount status isMountedRef.current = true
isMountedRef.current = true; addLog('debug', 'useMcp mounted, initiating connection.')
addLog('debug', 'Component mounted, initiating connection.'); connectAttemptRef.current = 0
connectAttemptRef.current = 0; // Reset attempts on mount/URL change // Ensure provider exists before connecting
connect(); // Initial connection attempt if (!authProviderRef.current) {
authProviderRef.current = new BrowserOAuthClientProvider(url, {
storageKeyPrefix,
clientName,
clientUri,
callbackUrl,
})
addLog('debug', 'BrowserOAuthClientProvider initialized on mount.')
}
connect() // Initial connection attempt
// Cleanup on unmount
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false
addLog('debug', 'Component unmounting, disconnecting.'); addLog('debug', 'useMcp unmounting, disconnecting.')
disconnect(); disconnect(true) // Quiet disconnect on unmount
}; }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
// Reconnect if these core config options change url, // Reconnect if URL changes
url,
storageKeyPrefix, storageKeyPrefix,
callbackUrl, callbackUrl,
// clientName, clientUri? Only if DCR is used. clientName,
// clientConfig.name, clientConfig.version? Usually stable. clientUri, // Reconnect if provider config changes
// connect, disconnect // connect/disconnect functions are stable due to useCallback // connect, disconnect are stable due to useCallback
]); ])
// Effect for auto-retry logic // Effect for auto-retry logic
useEffect(() => { useEffect(() => {
let retryTimeoutId: number | null = null; let retryTimeoutId: number | null = null
if (state === 'failed' && autoRetry && connectAttemptRef.current > 0) { if (state === 'failed' && autoRetry && connectAttemptRef.current > 0) {
// Only retry if the *initial* connection or subsequent retries failed const delay = typeof autoRetry === 'number' ? autoRetry : DEFAULT_RETRY_DELAY
const delay = typeof autoRetry === 'number' ? autoRetry : DEFAULT_RETRY_DELAY; addLog('info', `Connection failed, auto-retrying in ${delay}ms...`)
addLog('info', `Connection failed, auto-retrying in ${delay}ms...`);
retryTimeoutId = setTimeout(() => { retryTimeoutId = setTimeout(() => {
if (isMountedRef.current && state === 'failed') { // Check state again before retrying if (isMountedRef.current && state === 'failed') {
retry(); retry()
} }
}, delay); }, delay)
} }
return () => { return () => {
if (retryTimeoutId) { if (retryTimeoutId) clearTimeout(retryTimeoutId)
clearTimeout(retryTimeoutId); }
} }, [state, autoRetry, retry, addLog])
};
}, [state, autoRetry, retry, addLog]); // Depends on state and retry config
// Return the public API
return { return {
state, state,
tools, tools,
error, error,
log, log,
authUrl, authUrl, // Expose the manually retrieved auth URL
callTool, callTool,
retry, retry,
disconnect, disconnect,
authenticate, authenticate,
clearStorage, clearStorage,
}; }
} }