Gemini 2.5 refactor
This commit is contained in:
parent
6e83fa8955
commit
414e9e41fb
11 changed files with 1406 additions and 1051 deletions
278
src/auth/browser-provider.ts
Normal file
278
src/auth/browser-provider.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import { StoredState } from './types.js';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private storageKeyPrefix: string;
|
||||
private serverUrlHash: string;
|
||||
private clientName: string;
|
||||
private clientUri: string;
|
||||
private 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(
|
||||
readonly serverUrl: string,
|
||||
options: {
|
||||
storageKeyPrefix?: string;
|
||||
clientName?: string;
|
||||
clientUri?: string;
|
||||
callbackUrl?: string;
|
||||
} = {},
|
||||
) {
|
||||
this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth';
|
||||
// Hash the server URL to create unique storage keys per server
|
||||
this.serverUrlHash = this.hashString(serverUrl);
|
||||
this.clientName = options.clientName || 'MCP Browser Client';
|
||||
this.clientUri = options.clientUri || window.location.origin;
|
||||
// Default callback URL is /oauth/callback on the current origin
|
||||
this.callbackUrl = options.callbackUrl || new URL('/oauth/callback', window.location.origin).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The redirect URL used for the OAuth flow.
|
||||
*/
|
||||
get redirectUrl(): string {
|
||||
return this.callbackUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about this client, sent during dynamic client registration (if supported by the server).
|
||||
*/
|
||||
get clientMetadata() {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
token_endpoint_auth_method: 'none', // Public client
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
client_name: this.clientName,
|
||||
client_uri: this.clientUri,
|
||||
// scope: 'openid profile email mcp', // Example scopes, adjust as needed
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const key = this.getKey('client_info');
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) return undefined;
|
||||
|
||||
try {
|
||||
// TODO: Add validation using a schema (e.g., Zod)
|
||||
return JSON.parse(data) as OAuthClientInformation;
|
||||
} catch (e) {
|
||||
console.warn(`[${this.storageKeyPrefix}] Failed to parse client information from storage:`, e);
|
||||
localStorage.removeItem(key); // Clean up invalid data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves client information to storage.
|
||||
* @param clientInformation The client information to save.
|
||||
*/
|
||||
async saveClientInformation(clientInformation: OAuthClientInformation): Promise<void> {
|
||||
const key = this.getKey('client_info');
|
||||
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> {
|
||||
const key = this.getKey('tokens');
|
||||
const data = localStorage.getItem(key);
|
||||
if (!data) return undefined;
|
||||
|
||||
try {
|
||||
// TODO: Add validation using a schema (e.g., Zod)
|
||||
const tokens = JSON.parse(data) as OAuthTokens;
|
||||
// Optional: Check token expiry here if 'expires_at' is stored
|
||||
return tokens;
|
||||
} catch (e) {
|
||||
console.warn(`[${this.storageKeyPrefix}] Failed to parse tokens from storage:`, e);
|
||||
localStorage.removeItem(key); // Clean up invalid data
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves OAuth tokens to storage.
|
||||
* @param tokens The tokens to save.
|
||||
*/
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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> {
|
||||
const key = this.getKey('code_verifier');
|
||||
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> {
|
||||
const key = this.getKey('code_verifier');
|
||||
const verifier = localStorage.getItem(key);
|
||||
if (!verifier) {
|
||||
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
|
||||
// localStorage.removeItem(key);
|
||||
return verifier;
|
||||
}
|
||||
}
|
||||
179
src/auth/callback.ts
Normal file
179
src/auth/callback.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { exchangeAuthorization } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import { OAuthClientInformation, OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { StoredState } from './types.js';
|
||||
|
||||
/**
|
||||
* Handles the OAuth callback. This function should be invoked on your callback page
|
||||
* (e.g., /oauth/callback) with the URL query parameters.
|
||||
*
|
||||
* 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(
|
||||
query: Record<string, string>,
|
||||
{
|
||||
storageKeyPrefix = 'mcp:auth',
|
||||
}: {
|
||||
storageKeyPrefix?: string;
|
||||
} = {},
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const logPrefix = `[${storageKeyPrefix}-callback]`;
|
||||
console.log(`${logPrefix} Handling OAuth callback with query:`, query);
|
||||
|
||||
try {
|
||||
const code = query.code;
|
||||
const state = query.state;
|
||||
const error = query.error;
|
||||
const errorDescription = query.error_description;
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error} - ${errorDescription || 'No description provided.'}`);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new Error('Authorization code not found in callback query parameters.');
|
||||
}
|
||||
if (!state) {
|
||||
throw new Error('State parameter not found in callback query parameters.');
|
||||
}
|
||||
|
||||
// Retrieve the stored state using the state parameter from the query
|
||||
const stateKey = `${storageKeyPrefix}:state_${state}`;
|
||||
const storedStateJSON = localStorage.getItem(stateKey);
|
||||
// Remove the state key immediately after retrieval (it's single-use)
|
||||
localStorage.removeItem(stateKey);
|
||||
|
||||
if (!storedStateJSON) {
|
||||
throw new Error(`Invalid or expired state parameter "${state}". No matching state found in storage.`);
|
||||
}
|
||||
|
||||
let storedState: StoredState;
|
||||
try {
|
||||
storedState = JSON.parse(storedStateJSON);
|
||||
} catch (e) {
|
||||
throw new Error('Failed to parse stored OAuth state.');
|
||||
}
|
||||
|
||||
// Validate expiry
|
||||
if (storedState.expiry < Date.now()) {
|
||||
throw new Error('OAuth state has expired. Please try initiating authentication again.');
|
||||
}
|
||||
|
||||
const { authorizationUrl, serverUrlHash, metadata } = storedState;
|
||||
console.log(`${logPrefix} Found valid state for server hash: ${serverUrlHash}`);
|
||||
|
||||
// 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);
|
||||
// Remove the code verifier after retrieving it
|
||||
localStorage.removeItem(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.`);
|
||||
}
|
||||
|
||||
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...`);
|
||||
|
||||
// Exchange the code for tokens
|
||||
const tokens = await exchangeAuthorization(new URL(metadata.token_endpoint), {
|
||||
// Use token_endpoint from metadata
|
||||
metadata,
|
||||
clientInformation: clientInfo,
|
||||
authorizationCode: code,
|
||||
codeVerifier,
|
||||
// redirectUrl is typically required by the token endpoint for verification
|
||||
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...`);
|
||||
|
||||
// Save the obtained tokens
|
||||
localStorage.setItem(tokensKey, JSON.stringify(tokens));
|
||||
|
||||
// Clean up the persisted manual auth URL if it exists
|
||||
localStorage.removeItem(authUrlKey);
|
||||
|
||||
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 {
|
||||
// If there's no opener, this might be a full page redirect flow.
|
||||
// Redirect back to the app's main page or a specific post-auth page.
|
||||
console.warn(`${logPrefix} No opener window detected. Assuming redirect flow. Redirecting to root.`);
|
||||
window.location.href = '/'; // Adjust as needed
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error(`${logPrefix} Error during OAuth callback handling:`, err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
|
||||
// Attempt to notify the opener window about the failure
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage(
|
||||
{
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// Display error in the callback window itself for better debugging
|
||||
try {
|
||||
document.body.innerHTML = `
|
||||
<div style="font-family: sans-serif; padding: 20px;">
|
||||
<h1>Authentication Error</h1>
|
||||
<p style="color: red; background-color: #ffebeb; border: 1px solid red; padding: 10px; border-radius: 4px;">
|
||||
${errorMessage}
|
||||
</p>
|
||||
<p>You can close this window.</p>
|
||||
<pre style="font-size: 0.8em; color: #555; margin-top: 20px; white-space: pre-wrap;">${
|
||||
err instanceof Error ? err.stack : ''
|
||||
}</pre>
|
||||
</div>
|
||||
`;
|
||||
} catch (displayError) {
|
||||
// Fallback if document.body is not available or writable
|
||||
console.error(`${logPrefix} Could not display error in callback window:`, displayError);
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
12
src/auth/types.ts
Normal file
12
src/auth/types.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
|
||||
/**
|
||||
* Internal type for storing OAuth state in localStorage during the popup flow.
|
||||
* @internal
|
||||
*/
|
||||
export type StoredState = {
|
||||
authorizationUrl: string;
|
||||
metadata: OAuthMetadata;
|
||||
serverUrlHash: string; // To associate state with a specific server URL
|
||||
expiry: number; // Timestamp when the state expires
|
||||
};
|
||||
14
src/index.ts
Normal file
14
src/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Core entry point for the use-mcp browser library.
|
||||
* Provides browser-specific, framework-agnostic components for MCP OAuth.
|
||||
*/
|
||||
|
||||
export { BrowserOAuthClientProvider } from './auth/browser-provider.js';
|
||||
export { onMcpAuthorization } from './auth/callback.js';
|
||||
|
||||
// Optionally re-export relevant types from SDK or internal types if needed
|
||||
export type { StoredState as InternalStoredState } from './auth/types.js'; // Example if needed internally
|
||||
|
||||
// It's generally better to have users import SDK types directly from the SDK
|
||||
// export type { Tool, JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
|
||||
// export type { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { EventEmitter } from 'events'
|
||||
|
||||
/**
|
||||
* Options for creating an OAuth client provider
|
||||
*/
|
||||
export interface OAuthProviderOptions {
|
||||
/** Server URL to connect to */
|
||||
serverUrl: string
|
||||
/** Port for the OAuth callback server */
|
||||
callbackPort: number
|
||||
/** Path for the OAuth callback endpoint */
|
||||
callbackPath?: string
|
||||
/** Directory to store OAuth credentials */
|
||||
configDir?: string
|
||||
/** Client name to use for OAuth registration */
|
||||
clientName?: string
|
||||
/** Client URI to use for OAuth registration */
|
||||
clientUri?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth callback server setup options
|
||||
*/
|
||||
export interface OAuthCallbackServerOptions {
|
||||
/** Port for the callback server */
|
||||
port: number
|
||||
/** Path for the callback endpoint */
|
||||
path: string
|
||||
/** Event emitter to signal when auth code is received */
|
||||
events: EventEmitter
|
||||
}
|
||||
101
src/lib/utils.ts
101
src/lib/utils.ts
|
|
@ -1,101 +0,0 @@
|
|||
import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||||
|
||||
const pid = process.pid
|
||||
|
||||
/**
|
||||
* Creates a bidirectional proxy between two transports
|
||||
* @param params The transport connections to proxy between
|
||||
*/
|
||||
export function mcpProxy({ transportToClient, transportToServer }: { transportToClient: Transport; transportToServer: Transport }) {
|
||||
let transportToClientClosed = false
|
||||
let transportToServerClosed = false
|
||||
|
||||
transportToClient.onmessage = (message) => {
|
||||
// @ts-expect-error TODO
|
||||
console.error('[Local→Remote]', message.method || message.id)
|
||||
transportToServer.send(message).catch(onServerError)
|
||||
}
|
||||
|
||||
transportToServer.onmessage = (message) => {
|
||||
// @ts-expect-error TODO: fix this type
|
||||
console.error('[Remote→Local]', message.method || message.id)
|
||||
transportToClient.send(message).catch(onClientError)
|
||||
}
|
||||
|
||||
transportToClient.onclose = () => {
|
||||
if (transportToServerClosed) {
|
||||
return
|
||||
}
|
||||
|
||||
transportToClientClosed = true
|
||||
transportToServer.close().catch(onServerError)
|
||||
}
|
||||
|
||||
transportToServer.onclose = () => {
|
||||
if (transportToClientClosed) {
|
||||
return
|
||||
}
|
||||
transportToServerClosed = true
|
||||
transportToClient.close().catch(onClientError)
|
||||
}
|
||||
|
||||
transportToClient.onerror = onClientError
|
||||
transportToServer.onerror = onServerError
|
||||
|
||||
function onClientError(error: Error) {
|
||||
console.error('Error from local client:', error)
|
||||
}
|
||||
|
||||
function onServerError(error: Error) {
|
||||
console.error('Error from remote server:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and connects to a remote SSE server with OAuth authentication
|
||||
* @param serverUrl The URL of the remote server
|
||||
* @param authProvider The OAuth client provider
|
||||
* @param waitForAuthCode Function to wait for the auth code
|
||||
* @returns The connected SSE client transport
|
||||
*/
|
||||
export async function connectToRemoteServer(
|
||||
serverUrl: string,
|
||||
authProvider: OAuthClientProvider,
|
||||
waitForAuthCode: () => Promise<string>,
|
||||
): Promise<SSEClientTransport> {
|
||||
console.error(`[${pid}] Connecting to remote server: ${serverUrl}`)
|
||||
const url = new URL(serverUrl)
|
||||
const transport = new SSEClientTransport(url, { authProvider })
|
||||
|
||||
try {
|
||||
await transport.start()
|
||||
console.error('Connected to remote server')
|
||||
return transport
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) {
|
||||
console.error('Authentication required. Waiting for authorization...')
|
||||
|
||||
// Wait for the authorization code from the callback
|
||||
const code = await waitForAuthCode()
|
||||
|
||||
try {
|
||||
console.error('Completing authorization...')
|
||||
await transport.finishAuth(code)
|
||||
|
||||
// Create a new transport after auth
|
||||
const newTransport = new SSEClientTransport(url, { authProvider })
|
||||
await newTransport.start()
|
||||
console.error('Connected to remote server after authentication')
|
||||
return newTransport
|
||||
} catch (authError) {
|
||||
console.error('Authorization error:', authError)
|
||||
throw authError
|
||||
}
|
||||
} else {
|
||||
console.error('Connection error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/react/README.md
Normal file
173
src/react/README.md
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
# use-mcp React Integration
|
||||
|
||||
This package provides the `useMcp` hook for easily integrating the Model Context Protocol (MCP) into your React applications.
|
||||
|
||||
## Installation
|
||||
|
||||
Ensure you have `use-mcp`, `@modelcontextprotocol/sdk`, `react`, and `react-dom` installed:
|
||||
|
||||
```bash
|
||||
npm install use-mcp @modelcontextprotocol/sdk react react-dom
|
||||
# or
|
||||
yarn add use-mcp @modelcontextprotocol/sdk react react-dom
|
||||
# or
|
||||
pnpm add use-mcp @modelcontextprotocol/sdk react react-dom
|
||||
```
|
||||
|
||||
useMcp Hook
|
||||
|
||||
The useMcp hook manages the connection to an MCP server, handles authentication (including OAuth), and provides the list of available tools and functions to interact with the server.
|
||||
|
||||
## Importing
|
||||
|
||||
```tsx
|
||||
import { useMcp } from 'use-mcp/react';
|
||||
// Optional: Import types if needed
|
||||
import type { UseMcpOptions, UseMcpResult, Tool } from 'use-mcp/react';
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { useMcp } from 'use-mcp/react';
|
||||
import { useChat } from 'ai/react'; // Example integration with Vercel AI SDK
|
||||
|
||||
function MyChatComponent() {
|
||||
const mcp = useMcp({
|
||||
// Replace with your MCP server's SSE endpoint URL
|
||||
url: 'http://localhost:8080/sse',
|
||||
// Optional: Configure OAuth parameters if your server requires auth
|
||||
// clientName: 'My Awesome App',
|
||||
// clientUri: window.location.origin,
|
||||
// callbackUrl: `${window.location.origin}/oauth/callback`, // Must match your callback route
|
||||
// storageKeyPrefix: 'my_app_mcp_auth', // Customize localStorage keys
|
||||
debug: process.env.NODE_ENV === 'development', // Enable debug logs in dev
|
||||
autoReconnect: true, // Automatically reconnect if connection drops
|
||||
autoRetry: 5000, // Retry connection every 5s if initial connection fails
|
||||
});
|
||||
|
||||
// Example: Using MCP tools with Vercel AI SDK's useChat
|
||||
const { messages, input, handleInputChange, handleSubmit } = useChat({
|
||||
api: '/api/chat', // Your backend endpoint that talks to the LLM
|
||||
// Pass MCP tools to the AI SDK if the connection is ready
|
||||
tools: mcp.state === 'ready' ? mcp.tools : undefined,
|
||||
// Example: Handling tool calls made by the LLM
|
||||
async onToolCall({ toolCall }) {
|
||||
if (mcp.state !== 'ready') {
|
||||
console.error('MCP not ready, cannot handle tool call:', toolCall);
|
||||
// Return an error or indication that the tool cannot be called
|
||||
return {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
result: { error: 'Tool provider not available' }
|
||||
};
|
||||
}
|
||||
try {
|
||||
console.log('Handling tool call via MCP:', toolCall);
|
||||
const result = await mcp.callTool(
|
||||
toolCall.toolName,
|
||||
toolCall.args,
|
||||
);
|
||||
return {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
result,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling MCP tool:', error);
|
||||
return {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
result: { error: error instanceof Error ? error.message : String(error) }
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>MCP Status: {mcp.state}</h1>
|
||||
{mcp.state === 'failed' && (
|
||||
<div style={{ color: 'red' }}>
|
||||
Error: {mcp.error}
|
||||
{/* Show manual auth link if popup was blocked */}
|
||||
{mcp.authUrl && (
|
||||
<p>
|
||||
Popup blocked?{' '}
|
||||
<a href={mcp.authUrl} target="_blank" rel="noopener noreferrer">
|
||||
Authenticate manually
|
||||
</a>
|
||||
{' or '}
|
||||
<button onClick={() => mcp.authenticate()}>Retry Auth</button>
|
||||
</p>
|
||||
)}
|
||||
{/* Show retry button for general failures */}
|
||||
{!mcp.authUrl && <button onClick={mcp.retry}>Retry Connection</button>}
|
||||
</div>
|
||||
)}
|
||||
{mcp.state === 'authenticating' && <p>Waiting for authentication...</p>}
|
||||
{mcp.state === 'ready' && <p>Connected! Tools available: {mcp.tools.length}</p>}
|
||||
|
||||
{/* Your Chat UI */}
|
||||
<div>
|
||||
{messages.map((m) => (
|
||||
<div key={m.id}>
|
||||
<strong>{m.role}:</strong> {m.content}
|
||||
{/* Render tool calls/results if needed */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Ask something..."
|
||||
// Disable input if MCP is not ready and tools might be needed
|
||||
disabled={mcp.state !== 'ready' && mcp.state !== 'loading'}
|
||||
/>
|
||||
<button type="submit" disabled={mcp.state !== 'ready'}>Send</button>
|
||||
</form>
|
||||
|
||||
{/* Optional: Display logs for debugging */}
|
||||
{mcp.debug && (
|
||||
<pre style={{ maxHeight: '200px', overflowY: 'auto', fontSize: '0.8em', background: '#eee', padding: '5px' }}>
|
||||
{mcp.log.map((l, i) => <div key={i}>[{l.level}] {new Date(l.timestamp).toLocaleTimeString()}: {l.message}</div>)}
|
||||
</pre>
|
||||
)}
|
||||
{/* Button to clear stored auth data */}
|
||||
<button onClick={mcp.clearStorage} style={{marginTop: '10px'}}>Clear Stored Auth</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyChatComponent;
|
||||
```
|
||||
|
||||
Hook Options (UseMcpOptions)
|
||||
|
||||
* url (required): The /sse URL of your MCP server.
|
||||
* clientName: Name used for OAuth dynamic client registration (if applicable). Defaults to "MCP React Client".
|
||||
* clientUri: Client URI used for OAuth registration. Defaults to window.location.origin.
|
||||
* callbackUrl: The absolute URL of your OAuth callback page. Must match the redirect URI registered with your OAuth server and the route where you call onMcpAuthorization. Defaults to /oauth/callback on the current origin.
|
||||
* storageKeyPrefix: Prefix for keys used in localStorage (e.g., tokens, client info). Defaults to "mcp:auth". Useful to avoid conflicts if multiple MCP instances/apps run on the same origin.
|
||||
* clientConfig: Information about the client application sent during the MCP handshake. Includes name (default: "mcp-react-client") and version (default: "0.1.0").
|
||||
* debug: Enable verbose logging to the console and the log state array. Defaults to false.
|
||||
* autoRetry: If true or a number (milliseconds), automatically tries to reconnect if the initial connection fails. Defaults to false. Uses a 5000ms delay if set to true.
|
||||
* autoReconnect: If true or a number (milliseconds), automatically tries to reconnect if an established connection is lost. Defaults to 3000 (3 seconds). Set to false to disable.
|
||||
* popupFeatures: The features string passed to window.open for the OAuth popup. Defaults to "width=600,height=700,resizable=yes,scrollbars=yes,status=yes".
|
||||
|
||||
Hook Return Value (UseMcpResult)
|
||||
|
||||
* tools: An array of Tool objects provided by the server. Empty until state is 'ready'.
|
||||
* state: The current connection state ('discovering', 'authenticating', 'connecting', 'loading', 'ready', 'failed'). Use this to conditionally render UI or enable/disable features.
|
||||
* error: An error message if state is 'failed'.
|
||||
* authUrl: If authentication is required and the popup was potentially blocked, this URL string can be used to let the user manually open the auth page (e.g., <a href={authUrl} target="_blank">...</a>).
|
||||
* log: An array of log messages { level, message, timestamp }. Useful for debugging when debug option is true.
|
||||
* callTool(name, args): An async function to execute a tool on the MCP server. Throws an error if the client isn't ready or the call fails.
|
||||
* retry(): Manually triggers a reconnection attempt if the state is 'failed'.
|
||||
* disconnect(): Disconnects the client from the server.
|
||||
* authenticate(): Manually attempts to start the authentication flow. Useful for triggering the popup via a user click if it was initially blocked.
|
||||
* clearStorage(): Removes all authentication-related data (tokens, client info, code verifier, state) for the configured server URL from localStorage. Useful for development or allowing users to "log out". Automatically disconnects the client.
|
||||
|
||||
Setting up the OAuth Callback Route
|
||||
|
||||
Remember to create the callback route (e.g., /oauth/callback) specified in callbackUrl and have it execute the onMcpAuthorization function exported from the main use-mcp package. See the root README for an example.
|
||||
|
|
@ -1,922 +1,10 @@
|
|||
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 { discoverOAuthMetadata, exchangeAuthorization, startAuthorization } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
export type UseMcpOptions = {
|
||||
/** The /sse URL of your remote MCP server */
|
||||
url: string
|
||||
/** OAuth client name for registration */
|
||||
clientName?: string
|
||||
/** OAuth client URI for registration */
|
||||
clientUri?: string
|
||||
/** Custom callback URL for OAuth redirect (defaults to /oauth/callback on the current origin) */
|
||||
callbackUrl?: string
|
||||
/** Storage key prefix for OAuth data (defaults to "mcp_auth") */
|
||||
storageKeyPrefix?: string
|
||||
/** Custom configuration for the MCP client */
|
||||
clientConfig?: {
|
||||
name?: string
|
||||
version?: string
|
||||
}
|
||||
/** Whether to enable debug logging */
|
||||
debug?: boolean
|
||||
/** Auto retry connection if it fails, with delay in ms (default: false) */
|
||||
autoRetry?: boolean | number
|
||||
/** Auto reconnect if connection is lost, with delay in ms (default: 3000) */
|
||||
autoReconnect?: boolean | number
|
||||
/** Popup window features (dimensions and behavior) for OAuth */
|
||||
popupFeatures?: string
|
||||
}
|
||||
|
||||
export type UseMcpResult = {
|
||||
tools: Tool[]
|
||||
/**
|
||||
* The current state of the MCP connection. This will be one of:
|
||||
* - 'discovering': Finding out whether there is in fact a server at that URL, and what its capabilities are
|
||||
* - 'authenticating': The server has indicated we must authenticate, so we can't proceed until that's complete
|
||||
* - 'connecting': The connection to the MCP server is being established. This happens before we know whether we need to authenticate or not, and then again once we have credentials
|
||||
* - 'loading': We're connected to the MCP server, and now we're loading its resources/prompts/tools
|
||||
* - 'ready': The MCP server is connected and ready to be used
|
||||
* - 'failed': The connection to the MCP server failed
|
||||
* */
|
||||
state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed'
|
||||
/** If the state is 'failed', this will be the error message */
|
||||
error?: string
|
||||
/**
|
||||
* If authorization was blocked, this will contain the URL to authorize manually
|
||||
* The app can render this as a link with target="_blank" so the user can complete
|
||||
* authorization without leaving the app
|
||||
*/
|
||||
authUrl?: string
|
||||
/** All internal log messages */
|
||||
log: { level: 'debug' | 'info' | 'warn' | 'error'; message: string }[]
|
||||
/** Call a tool on the MCP server */
|
||||
callTool: (name: string, args?: Record<string, unknown>) => Promise<any>
|
||||
/** Manually retry connection if it's in a failed state */
|
||||
retry: () => void
|
||||
/** Manually disconnect from the MCP server */
|
||||
disconnect: () => void
|
||||
/**
|
||||
* Manually trigger authentication
|
||||
* @returns Auth URL that can be used to manually open a new window
|
||||
*/
|
||||
authenticate: () => Promise<string | undefined>
|
||||
/**
|
||||
* Clear all localStorage items for this server
|
||||
*/
|
||||
clearStorage: () => void
|
||||
}
|
||||
|
||||
type StoredState = {
|
||||
authorizationUrl: string
|
||||
metadata: OAuthMetadata
|
||||
serverUrlHash: string
|
||||
expiry: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-compatible OAuth client provider for MCP
|
||||
* Entry point for the use-mcp React integration.
|
||||
* Provides the useMcp hook and related types.
|
||||
*/
|
||||
class BrowserOAuthClientProvider {
|
||||
private storageKeyPrefix: string
|
||||
private serverUrlHash: string
|
||||
private clientName: string
|
||||
private clientUri: string
|
||||
private callbackUrl: string
|
||||
|
||||
constructor(
|
||||
readonly serverUrl: string,
|
||||
options: {
|
||||
storageKeyPrefix?: string
|
||||
clientName?: string
|
||||
clientUri?: string
|
||||
callbackUrl?: string
|
||||
} = {},
|
||||
) {
|
||||
this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth'
|
||||
this.serverUrlHash = this.hashString(serverUrl)
|
||||
this.clientName = options.clientName || 'MCP Browser Client'
|
||||
this.clientUri = options.clientUri || window.location.origin
|
||||
this.callbackUrl = options.callbackUrl || new URL('/oauth/callback', window.location.origin).toString()
|
||||
}
|
||||
export { useMcp } from './useMcp.js';
|
||||
export type { UseMcpOptions, UseMcpResult } from './types.js';
|
||||
|
||||
get redirectUrl(): string {
|
||||
return this.callbackUrl
|
||||
}
|
||||
|
||||
get clientMetadata() {
|
||||
return {
|
||||
redirect_uris: [this.redirectUrl],
|
||||
token_endpoint_auth_method: 'none',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
client_name: this.clientName,
|
||||
client_uri: this.clientUri,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all storage items related to this server
|
||||
* @returns The number of items cleared
|
||||
*/
|
||||
clearStorage(): number {
|
||||
const prefix = `${this.storageKeyPrefix}_${this.serverUrlHash}`;
|
||||
const keysToRemove = [];
|
||||
|
||||
// Find all keys that match the prefix
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith(prefix)) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for any state keys
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith(`${this.storageKeyPrefix}:state_`)) {
|
||||
// Load state to check if it's for this server
|
||||
try {
|
||||
const state = JSON.parse(localStorage.getItem(key) || '{}');
|
||||
if (state.serverUrlHash === this.serverUrlHash) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove all matching keys
|
||||
keysToRemove.forEach(key => localStorage.removeItem(key));
|
||||
|
||||
return keysToRemove.length;
|
||||
}
|
||||
|
||||
private hashString(str: string): string {
|
||||
// Simple hash function for browser environments
|
||||
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
|
||||
}
|
||||
return Math.abs(hash).toString(16)
|
||||
}
|
||||
|
||||
private getKey(key: string): string {
|
||||
return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}`
|
||||
}
|
||||
|
||||
async clientInformation(): Promise<OAuthClientInformation | undefined> {
|
||||
const key = this.getKey('client_info')
|
||||
const data = localStorage.getItem(key)
|
||||
if (!data) return undefined
|
||||
|
||||
try {
|
||||
return JSON.parse(data) as OAuthClientInformation
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async saveClientInformation(clientInformation: OAuthClientInformation): Promise<void> {
|
||||
const key = this.getKey('client_info')
|
||||
localStorage.setItem(key, JSON.stringify(clientInformation))
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
const key = this.getKey('tokens')
|
||||
const data = localStorage.getItem(key)
|
||||
if (!data) return undefined
|
||||
|
||||
try {
|
||||
return JSON.parse(data) as OAuthTokens
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const key = this.getKey('tokens')
|
||||
localStorage.setItem(key, JSON.stringify(tokens))
|
||||
}
|
||||
|
||||
async redirectToAuthorization(
|
||||
authorizationUrl: URL,
|
||||
metadata: OAuthMetadata,
|
||||
options?: {
|
||||
popupFeatures?: string
|
||||
},
|
||||
): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> {
|
||||
// Store the auth state for the popup flow
|
||||
const state = Math.random().toString(36).substring(2)
|
||||
const stateKey = `${this.storageKeyPrefix}:state_${state}`
|
||||
localStorage.setItem(
|
||||
stateKey,
|
||||
JSON.stringify({
|
||||
authorizationUrl: authorizationUrl.toString(),
|
||||
metadata,
|
||||
serverUrlHash: this.serverUrlHash,
|
||||
expiry: +new Date() + 1000 * 60 * 5 /* 5 minutes */,
|
||||
} as StoredState),
|
||||
)
|
||||
authorizationUrl.searchParams.set('state', state)
|
||||
|
||||
const authUrl = authorizationUrl.toString()
|
||||
const popupFeatures = options?.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes'
|
||||
|
||||
// Store the auth URL in case we need it for manual authentication
|
||||
localStorage.setItem(this.getKey('auth_url'), authUrl)
|
||||
|
||||
try {
|
||||
// Open the authorization URL in a popup window
|
||||
const popup = window.open(authUrl, 'mcp_auth', popupFeatures)
|
||||
|
||||
// Check if popup was blocked or closed immediately
|
||||
if (!popup || popup.closed || popup.closed === undefined) {
|
||||
console.warn('Popup blocked. Returning error.')
|
||||
return { success: false, popupBlocked: true, url: authUrl }
|
||||
}
|
||||
|
||||
// Try to access the popup to confirm it's not blocked
|
||||
try {
|
||||
// Just accessing any property will throw if popup is blocked
|
||||
const popupLocation = popup.location
|
||||
// If we can read location.href, the popup is definitely working
|
||||
if (popupLocation.href) {
|
||||
// Successfully opened popup
|
||||
return { success: true, url: authUrl }
|
||||
}
|
||||
} catch (e) {
|
||||
// Access to the popup was denied, indicating it's blocked
|
||||
console.warn('Popup blocked (security exception).')
|
||||
return { success: false, popupBlocked: true, url: authUrl }
|
||||
}
|
||||
|
||||
// If we got here, popup is working
|
||||
return { success: true, url: authUrl }
|
||||
} catch (e) {
|
||||
// Error opening popup
|
||||
console.warn('Error opening popup:', e)
|
||||
return { success: false, popupBlocked: true, url: authUrl }
|
||||
}
|
||||
}
|
||||
|
||||
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
||||
const key = this.getKey('code_verifier')
|
||||
localStorage.setItem(key, codeVerifier)
|
||||
}
|
||||
|
||||
async codeVerifier(): Promise<string> {
|
||||
const key = this.getKey('code_verifier')
|
||||
const verifier = localStorage.getItem(key)
|
||||
if (!verifier) {
|
||||
throw new Error('No code verifier found in storage')
|
||||
}
|
||||
return verifier
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* useMcp is a React hook that connects to a remote MCP server, negotiates auth
|
||||
* (including opening a popup window or new tab to complete the OAuth flow),
|
||||
* and enables passing a list of tools (once loaded) to ai-sdk (using `useChat`).
|
||||
*/
|
||||
export function useMcp(options: UseMcpOptions): UseMcpResult {
|
||||
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 metadataRef = useRef<OAuthMetadata | undefined>(undefined)
|
||||
const authUrlRef = useRef<URL | undefined>(undefined)
|
||||
const codeVerifierRef = useRef<string | undefined>(undefined)
|
||||
const connectingRef = useRef<boolean>(false)
|
||||
const isInitialMount = useRef<boolean>(true)
|
||||
|
||||
// Set up default options
|
||||
const {
|
||||
url,
|
||||
clientName = 'MCP React Client',
|
||||
clientUri = window.location.origin,
|
||||
callbackUrl = new URL('/oauth/callback', window.location.origin).toString(),
|
||||
storageKeyPrefix = 'mcp:auth',
|
||||
clientConfig = {
|
||||
name: 'mcp-react-client',
|
||||
version: '0.1.0',
|
||||
},
|
||||
debug = false,
|
||||
autoRetry = false,
|
||||
autoReconnect = 3000,
|
||||
popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes',
|
||||
} = options
|
||||
|
||||
// Add to log
|
||||
const addLog = useCallback(
|
||||
(level: 'debug' | 'info' | 'warn' | 'error', message: string) => {
|
||||
if (level === 'debug' && !debug) return
|
||||
setLog((prevLog) => [...prevLog, { level, message }])
|
||||
},
|
||||
[debug],
|
||||
)
|
||||
|
||||
// Call a tool on the MCP server
|
||||
const callTool = useCallback(
|
||||
async (name: string, args?: Record<string, unknown>) => {
|
||||
if (!clientRef.current || state !== 'ready') {
|
||||
throw new Error('MCP client not ready')
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('CALLING TOOL')
|
||||
const result = await clientRef.current.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
CallToolResultSchema,
|
||||
)
|
||||
return result
|
||||
} catch (err) {
|
||||
addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`)
|
||||
throw err
|
||||
}
|
||||
},
|
||||
[state, addLog],
|
||||
)
|
||||
|
||||
// Disconnect from the MCP server
|
||||
const disconnect = useCallback(async () => {
|
||||
if (clientRef.current) {
|
||||
try {
|
||||
await clientRef.current.close()
|
||||
} catch (err) {
|
||||
addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
clientRef.current = null
|
||||
}
|
||||
|
||||
if (transportRef.current) {
|
||||
try {
|
||||
await transportRef.current.close()
|
||||
} catch (err) {
|
||||
addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
transportRef.current = null
|
||||
}
|
||||
|
||||
connectingRef.current = false
|
||||
setState('discovering')
|
||||
setTools([])
|
||||
setError(undefined)
|
||||
}, [addLog])
|
||||
|
||||
// Start the auth flow and get the auth URL
|
||||
const startAuthFlow = useCallback(async (): Promise<URL | undefined> => {
|
||||
if (!authProviderRef.current || !metadataRef.current) {
|
||||
throw new Error('Auth provider or metadata not available')
|
||||
}
|
||||
|
||||
addLog('info', 'Starting authentication flow...')
|
||||
|
||||
// Check if we have client info
|
||||
let clientInfo = await authProviderRef.current.clientInformation()
|
||||
|
||||
if (!clientInfo) {
|
||||
// Register client dynamically
|
||||
addLog('info', 'No client information found, registering...')
|
||||
// Note: In a complete implementation, you'd register the client here
|
||||
// This would be done server-side in a real application
|
||||
throw new Error('Dynamic client registration not implemented in this example')
|
||||
}
|
||||
|
||||
// Start authorization flow
|
||||
addLog('info', 'Preparing authorization...')
|
||||
const { authorizationUrl, codeVerifier } = await startAuthorization(url, {
|
||||
metadata: metadataRef.current,
|
||||
clientInformation: clientInfo,
|
||||
redirectUrl: authProviderRef.current.redirectUrl,
|
||||
})
|
||||
|
||||
// Save code verifier and auth URL for later use
|
||||
await authProviderRef.current.saveCodeVerifier(codeVerifier)
|
||||
codeVerifierRef.current = codeVerifier
|
||||
authUrlRef.current = authorizationUrl
|
||||
setAuthUrl(authorizationUrl.toString())
|
||||
|
||||
return authorizationUrl
|
||||
}, [url, addLog])
|
||||
|
||||
// Handle authentication flow
|
||||
const handleAuthentication = useCallback(async () => {
|
||||
if (!authProviderRef.current) {
|
||||
throw new Error('Auth provider not available')
|
||||
}
|
||||
|
||||
// Get or create the auth URL
|
||||
if (!authUrlRef.current) {
|
||||
try {
|
||||
await startAuthFlow()
|
||||
} catch (err) {
|
||||
addLog('error', `Failed to start auth flow: ${err instanceof Error ? err.message : String(err)}`)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
if (!authUrlRef.current) {
|
||||
throw new Error('Failed to create authorization URL')
|
||||
}
|
||||
|
||||
// Set up listener for post-auth message
|
||||
const authPromise = new Promise<string>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
reject(new Error('Authentication timeout after 5 minutes'))
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
// Verify origin for security
|
||||
if (event.origin !== window.location.origin) return
|
||||
|
||||
if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
// TODO: not this, obviously
|
||||
// reload window, we should find the token in local storage
|
||||
window.location.reload()
|
||||
// resolve(event.data.code)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', messageHandler)
|
||||
})
|
||||
|
||||
// Redirect to authorization
|
||||
addLog('info', 'Opening authorization window...')
|
||||
assert(metadataRef.current, 'Metadata not available')
|
||||
const redirectResult = await authProviderRef.current.redirectToAuthorization(authUrlRef.current, metadataRef.current, {
|
||||
popupFeatures,
|
||||
})
|
||||
|
||||
if (!redirectResult.success) {
|
||||
// Popup was blocked
|
||||
setState('failed')
|
||||
setError('Authentication popup was blocked by the browser. Please click the link to authenticate in a new window.')
|
||||
setAuthUrl(redirectResult.url)
|
||||
addLog('warn', 'Authentication popup was blocked. User needs to manually authorize.')
|
||||
throw new Error('Authentication popup blocked')
|
||||
}
|
||||
|
||||
// Wait for auth to complete
|
||||
addLog('info', 'Waiting for authorization...')
|
||||
const code = await authPromise
|
||||
addLog('info', 'Authorization code received')
|
||||
|
||||
return code
|
||||
}, [url, addLog, popupFeatures, startAuthFlow])
|
||||
|
||||
// Initialize connection to MCP server
|
||||
const connect = useCallback(async () => {
|
||||
// Prevent multiple simultaneous connection attempts
|
||||
if (connectingRef.current) return
|
||||
connectingRef.current = true
|
||||
|
||||
try {
|
||||
setState('discovering')
|
||||
setError(undefined)
|
||||
|
||||
// Create auth provider if not already created
|
||||
if (!authProviderRef.current) {
|
||||
authProviderRef.current = new BrowserOAuthClientProvider(url, {
|
||||
storageKeyPrefix,
|
||||
clientName,
|
||||
clientUri,
|
||||
callbackUrl,
|
||||
})
|
||||
}
|
||||
|
||||
// Create MCP client
|
||||
clientRef.current = new Client(
|
||||
{
|
||||
name: clientConfig.name || 'mcp-react-client',
|
||||
version: clientConfig.version || '0.1.0',
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
sampling: {},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Create SSE transport - try connecting without auth first
|
||||
setState('connecting')
|
||||
addLog('info', 'Creating transport...')
|
||||
|
||||
const serverUrl = new URL(url)
|
||||
transportRef.current = new SSEClientTransport(serverUrl, {
|
||||
// @ts-expect-error TODO: fix this type, expect BrowserOAuthClientProvider
|
||||
authProvider: authProviderRef.current,
|
||||
})
|
||||
|
||||
// Set up transport handlers
|
||||
transportRef.current.onmessage = (message: JSONRPCMessage) => {
|
||||
// @ts-expect-error TODO: fix this type
|
||||
addLog('debug', `Received message: ${message.method || message.id}`)
|
||||
}
|
||||
|
||||
transportRef.current.onerror = (err: Error) => {
|
||||
addLog('error', `Transport error: ${err.message}`)
|
||||
|
||||
if (err.message.includes('Unauthorized')) {
|
||||
// Only discover OAuth metadata and authenticate if we get a 401
|
||||
discoverOAuthAndAuthenticate(err)
|
||||
} else {
|
||||
setState('failed')
|
||||
setError(`Connection error: ${err.message}`)
|
||||
connectingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
transportRef.current.onclose = () => {
|
||||
addLog('info', 'Connection closed')
|
||||
// If we were previously connected, try to reconnect
|
||||
if (state === 'ready' && autoReconnect) {
|
||||
const delay = typeof autoReconnect === 'number' ? autoReconnect : 3000
|
||||
addLog('info', `Will reconnect in ${delay}ms...`)
|
||||
setTimeout(() => {
|
||||
disconnect().then(() => connect())
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to handle OAuth discovery and authentication
|
||||
const discoverOAuthAndAuthenticate = async (error: Error) => {
|
||||
try {
|
||||
// Discover OAuth metadata now that we know we need it
|
||||
if (!metadataRef.current) {
|
||||
addLog('info', 'Discovering OAuth metadata...')
|
||||
metadataRef.current = await discoverOAuthMetadata(url)
|
||||
addLog('debug', `OAuth metadata: ${metadataRef.current ? 'Found' : 'Not available'}`)
|
||||
}
|
||||
|
||||
// If metadata is found, start auth flow
|
||||
if (metadataRef.current) {
|
||||
setState('authenticating')
|
||||
// Start authentication process
|
||||
await handleAuthentication()
|
||||
// After successful auth, retry connection
|
||||
return connect()
|
||||
} else {
|
||||
// No OAuth metadata available
|
||||
setState('failed')
|
||||
setError(`Authentication required but no OAuth metadata found: ${error.message}`)
|
||||
connectingRef.current = false
|
||||
}
|
||||
} catch (oauthErr) {
|
||||
addLog('error', `OAuth discovery error: ${oauthErr instanceof Error ? oauthErr.message : String(oauthErr)}`)
|
||||
setState('failed')
|
||||
setError(`Authentication setup failed: ${oauthErr instanceof Error ? oauthErr.message : String(oauthErr)}`)
|
||||
connectingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// Try connecting transport first without OAuth discovery
|
||||
try {
|
||||
addLog('info', 'Starting transport...')
|
||||
// await transportRef.current.start()
|
||||
} catch (err) {
|
||||
addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
|
||||
if (err instanceof Error && err.message.includes('Unauthorized')) {
|
||||
// Only discover OAuth and authenticate if we get a 401
|
||||
await discoverOAuthAndAuthenticate(err)
|
||||
} else {
|
||||
setState('failed')
|
||||
setError(`Connection error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
connectingRef.current = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Connect client
|
||||
try {
|
||||
addLog('info', 'Connecting client...')
|
||||
setState('loading')
|
||||
await clientRef.current.connect(transportRef.current)
|
||||
addLog('info', 'Client connected')
|
||||
|
||||
// Load tools
|
||||
try {
|
||||
addLog('info', 'Loading tools...')
|
||||
const toolsResponse = await clientRef.current.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||
setTools(toolsResponse.tools)
|
||||
addLog('info', `Loaded ${toolsResponse.tools.length} tools`)
|
||||
|
||||
// Connection completed successfully
|
||||
setState('ready')
|
||||
connectingRef.current = false
|
||||
} catch (toolErr) {
|
||||
addLog('error', `Error loading tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`)
|
||||
// We're still connected, just couldn't load tools
|
||||
setState('ready')
|
||||
connectingRef.current = false
|
||||
}
|
||||
} catch (connectErr) {
|
||||
addLog('error', `Client connect error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`)
|
||||
|
||||
if (connectErr instanceof Error && connectErr.message.includes('Unauthorized')) {
|
||||
// Only discover OAuth and authenticate if we get a 401
|
||||
await discoverOAuthAndAuthenticate(connectErr)
|
||||
} else {
|
||||
setState('failed')
|
||||
setError(`Connection error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`)
|
||||
connectingRef.current = false
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
addLog('error', `Unexpected error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
setState('failed')
|
||||
setError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
connectingRef.current = false
|
||||
}
|
||||
}, [
|
||||
url,
|
||||
clientName,
|
||||
clientUri,
|
||||
callbackUrl,
|
||||
storageKeyPrefix,
|
||||
clientConfig,
|
||||
debug,
|
||||
autoReconnect,
|
||||
addLog,
|
||||
handleAuthentication,
|
||||
disconnect,
|
||||
])
|
||||
|
||||
// Provide public authenticate method
|
||||
const authenticate = useCallback(async (): Promise<string | undefined> => {
|
||||
if (!authUrlRef.current) {
|
||||
await startAuthFlow()
|
||||
}
|
||||
|
||||
if (authUrlRef.current) {
|
||||
return authUrlRef.current.toString()
|
||||
}
|
||||
return undefined
|
||||
}, [])
|
||||
|
||||
// Handle auth completion - this is called when we receive a message from the popup
|
||||
const handleAuthCompletion = useCallback(
|
||||
async (code: string) => {
|
||||
if (!authProviderRef.current || !transportRef.current) {
|
||||
throw new Error('Authentication context not available')
|
||||
}
|
||||
|
||||
try {
|
||||
addLog('info', 'Finishing authorization...')
|
||||
await transportRef.current.finishAuth(code)
|
||||
addLog('info', 'Authorization completed')
|
||||
|
||||
// Reset auth URL state
|
||||
authUrlRef.current = undefined
|
||||
setAuthUrl(undefined)
|
||||
|
||||
// Reconnect with the new auth token
|
||||
await disconnect()
|
||||
connect()
|
||||
} catch (err) {
|
||||
addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
setState('failed')
|
||||
setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
[addLog, disconnect, connect],
|
||||
)
|
||||
|
||||
// Retry connection
|
||||
const retry = useCallback(() => {
|
||||
if (state === 'failed') {
|
||||
disconnect().then(() => connect())
|
||||
}
|
||||
}, [state, disconnect, connect])
|
||||
|
||||
// Set up message listener for auth callback
|
||||
useEffect(() => {
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
// Verify origin for security
|
||||
if (event.origin !== window.location.origin) return
|
||||
|
||||
if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) {
|
||||
handleAuthCompletion(event.data.code).catch((err) => {
|
||||
addLog('error', `Auth callback error: ${err.message}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', messageHandler)
|
||||
return () => {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
}
|
||||
}, [handleAuthCompletion, addLog])
|
||||
|
||||
// Initial connection and auto-retry
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false
|
||||
connect()
|
||||
} else if (state === 'failed' && autoRetry) {
|
||||
const delay = typeof autoRetry === 'number' ? autoRetry : 5000
|
||||
const timeoutId = setTimeout(() => {
|
||||
addLog('info', 'Auto-retrying connection...')
|
||||
disconnect().then(() => connect())
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}, [state, autoRetry, connect, disconnect, addLog])
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clientRef.current || transportRef.current) {
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
}, [disconnect])
|
||||
|
||||
// Clear all localStorage items for this server
|
||||
const clearStorage = useCallback(() => {
|
||||
if (!authProviderRef.current) {
|
||||
addLog('warn', 'Cannot clear storage: auth provider not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the provider's method to clear storage
|
||||
const clearedCount = authProviderRef.current.clearStorage();
|
||||
|
||||
// Clear auth-related state in the hook
|
||||
authUrlRef.current = undefined;
|
||||
setAuthUrl(undefined);
|
||||
metadataRef.current = undefined;
|
||||
codeVerifierRef.current = undefined;
|
||||
|
||||
addLog('info', `Cleared ${clearedCount} storage items for server`);
|
||||
}, [addLog]);
|
||||
|
||||
return {
|
||||
state,
|
||||
tools,
|
||||
error,
|
||||
log,
|
||||
authUrl,
|
||||
callTool,
|
||||
retry,
|
||||
disconnect,
|
||||
authenticate,
|
||||
clearStorage,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* onMcpAuthorization is invoked when the oauth flow completes. This is usually mounted
|
||||
* on /oauth/callback, and passed the entire URL query parameters. This first uses the state
|
||||
* parameter to look up in LocalStorage the context for the current auth flow, and then
|
||||
* completes the flow by exchanging the authorization code for an access token.
|
||||
*
|
||||
* Once it's updated LocalStorage with the auth token, it will post a message back to the original
|
||||
* window to inform any running `useMcp` hooks that the auth flow is complete.
|
||||
*/
|
||||
export async function onMcpAuthorization(
|
||||
query: Record<string, string>,
|
||||
{
|
||||
storageKeyPrefix = 'mcp:auth',
|
||||
}: {
|
||||
storageKeyPrefix?: string
|
||||
} = {},
|
||||
) {
|
||||
try {
|
||||
// Extract the authorization code and state
|
||||
const code = query.code
|
||||
const state = query.state
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received')
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
throw new Error('No state parameter received')
|
||||
}
|
||||
|
||||
// Find the matching auth state in localStorage
|
||||
// const storageKeys = Object.keys(localStorage).filter((key) => key.includes('_auth_state') && localStorage.getItem(key) === state)
|
||||
|
||||
const stateKey = `${storageKeyPrefix}:state_${state}`
|
||||
const storedState = localStorage.getItem(stateKey)
|
||||
console.log({ stateKey, storedState })
|
||||
if (!storedState) {
|
||||
throw new Error('No matching auth state found in storage')
|
||||
}
|
||||
const { authorizationUrl, serverUrlHash, metadata, expiry } = JSON.parse(storedState)
|
||||
if (expiry < Date.now()) {
|
||||
throw new Error('Auth state has expired')
|
||||
}
|
||||
|
||||
// Find all related auth data with the same prefix and server hash
|
||||
const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info`
|
||||
const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier`
|
||||
console.log({ authorizationUrl, clientInfoKey, codeVerifierKey })
|
||||
|
||||
const clientInfoStr = localStorage.getItem(clientInfoKey)
|
||||
const codeVerifier = localStorage.getItem(codeVerifierKey)
|
||||
|
||||
if (!clientInfoStr) {
|
||||
throw new Error('No client information found in storage')
|
||||
}
|
||||
|
||||
if (!codeVerifier) {
|
||||
throw new Error('No code verifier found in storage')
|
||||
}
|
||||
|
||||
// Parse client info
|
||||
const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation
|
||||
|
||||
const tokens = await exchangeAuthorization(new URL('/', authorizationUrl), {
|
||||
metadata,
|
||||
clientInformation: clientInfo,
|
||||
authorizationCode: code,
|
||||
codeVerifier,
|
||||
})
|
||||
|
||||
// Save the tokens
|
||||
const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens`
|
||||
console.log({ tokensKey, tokens })
|
||||
localStorage.setItem(tokensKey, JSON.stringify(tokens))
|
||||
|
||||
// Post message back to the parent window
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: 'mcp_auth_callback',
|
||||
},
|
||||
window.location.origin,
|
||||
)
|
||||
// Close the popup
|
||||
window.close()
|
||||
} else {
|
||||
// If no parent window, we're in a redirect flow
|
||||
// Redirect back to the main page
|
||||
window.location.href = '/'
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error('Error in MCP authorization:', error)
|
||||
|
||||
// Create a readable error message for display
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
|
||||
// If the popup is still open, show the error
|
||||
const errorHtml = `
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Error</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 2rem; line-height: 1.5; }
|
||||
.error { color: #e53e3e; background: #fed7d7; padding: 1rem; border-radius: 0.25rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Authentication Error</h1>
|
||||
<div class="error">
|
||||
<p>${errorMessage}</p>
|
||||
</div>
|
||||
<p>You can close this window and try again.</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
document.body.innerHTML = errorHtml
|
||||
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
// Re-export core Tool type for convenience when using hook result?
|
||||
export type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
72
src/react/types.ts
Normal file
72
src/react/types.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
export type UseMcpOptions = {
|
||||
/** The /sse URL of your remote MCP server */
|
||||
url: string;
|
||||
/** OAuth client name for registration (if dynamic registration is used) */
|
||||
clientName?: string;
|
||||
/** OAuth client URI for registration (if dynamic registration is used) */
|
||||
clientUri?: string;
|
||||
/** Custom callback URL for OAuth redirect (defaults to /oauth/callback on the current origin) */
|
||||
callbackUrl?: string;
|
||||
/** Storage key prefix for OAuth data in localStorage (defaults to "mcp:auth") */
|
||||
storageKeyPrefix?: string;
|
||||
/** Custom configuration for the MCP client identity */
|
||||
clientConfig?: {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
/** Whether to enable verbose debug logging to the console and the log state */
|
||||
debug?: boolean;
|
||||
/** Auto retry connection if initial connection fails, with delay in ms (default: false) */
|
||||
autoRetry?: boolean | number;
|
||||
/** Auto reconnect if an established connection is lost, with delay in ms (default: 3000) */
|
||||
autoReconnect?: boolean | number;
|
||||
/** Popup window features string (dimensions and behavior) for OAuth */
|
||||
popupFeatures?: string;
|
||||
};
|
||||
|
||||
export type UseMcpResult = {
|
||||
/** List of tools available from the connected MCP server */
|
||||
tools: Tool[];
|
||||
/**
|
||||
* The current state of the MCP connection:
|
||||
* - 'discovering': Checking server existence and capabilities (including auth requirements).
|
||||
* - 'authenticating': Authentication is required and the process (e.g., popup) has been initiated.
|
||||
* - 'connecting': Establishing the SSE connection to the server.
|
||||
* - 'loading': Connected; loading resources like the tool list.
|
||||
* - 'ready': Connected and ready for tool calls.
|
||||
* - 'failed': Connection or authentication failed. Check the `error` property.
|
||||
*/
|
||||
state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed';
|
||||
/** If the state is 'failed', this provides the error message */
|
||||
error?: string;
|
||||
/**
|
||||
* If authentication requires user interaction (e.g., popup was blocked),
|
||||
* this URL can be presented to the user to complete authentication manually in a new tab.
|
||||
*/
|
||||
authUrl?: string;
|
||||
/** Array of internal log messages (useful for debugging) */
|
||||
log: { level: 'debug' | 'info' | 'warn' | 'error'; message: string; timestamp: number }[];
|
||||
/**
|
||||
* Function to call a tool on the MCP server.
|
||||
* @param name The name of the tool to call.
|
||||
* @param args Optional arguments for the tool.
|
||||
* @returns A promise that resolves with the tool's result.
|
||||
* @throws If the client is not in the 'ready' state or the call fails.
|
||||
*/
|
||||
callTool: (name: string, args?: Record<string, unknown>) => Promise<any>;
|
||||
/** Manually attempts to reconnect if the state is 'failed'. */
|
||||
retry: () => void;
|
||||
/** Disconnects the client from the MCP server. */
|
||||
disconnect: () => void;
|
||||
/**
|
||||
* Manually triggers the authentication process. Useful if the initial attempt failed
|
||||
* due to a blocked popup, allowing the user to initiate it via a button click.
|
||||
* @returns A promise that resolves with the authorization URL opened (or intended to be opened),
|
||||
* or undefined if auth cannot be started.
|
||||
*/
|
||||
authenticate: () => Promise<string | undefined>;
|
||||
/** Clears all stored authentication data (tokens, client info, etc.) for this server URL from localStorage. */
|
||||
clearStorage: () => void;
|
||||
};
|
||||
570
src/react/useMcp.ts
Normal file
570
src/react/useMcp.ts
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
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 { discoverOAuthMetadata, exchangeAuthorization, startAuthorization, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { BrowserOAuthClientProvider } from '../../auth/browser-provider.js';
|
||||
import { assert } from '../../utils/assert.js';
|
||||
import type { UseMcpOptions, UseMcpResult } from './types.js';
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const {
|
||||
url,
|
||||
clientName, // Passed to BrowserOAuthClientProvider
|
||||
clientUri, // Passed to BrowserOAuthClientProvider
|
||||
callbackUrl = typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback',
|
||||
storageKeyPrefix = 'mcp:auth',
|
||||
clientConfig = {},
|
||||
debug = false,
|
||||
autoRetry = false,
|
||||
autoReconnect = DEFAULT_RECONNECT_DELAY,
|
||||
popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes',
|
||||
} = 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']>([]);
|
||||
const [authUrl, setAuthUrl] = useState<string | undefined>(undefined); // For manual auth if popup blocked
|
||||
|
||||
const clientRef = useRef<Client | null>(null);
|
||||
const transportRef = useRef<SSEClientTransport | null>(null);
|
||||
const authProviderRef = useRef<BrowserOAuthClientProvider | null>(null);
|
||||
const metadataRef = useRef<OAuthMetadata | undefined>(undefined);
|
||||
const pendingAuthUrlRef = useRef<URL | undefined>(undefined); // Stores the URL while waiting for popup
|
||||
const connectingRef = useRef<boolean>(false); // Prevent concurrent connection attempts
|
||||
const isMountedRef = useRef<boolean>(true); // Track component mount status
|
||||
const connectAttemptRef = useRef<number>(0); // Track connection attempts for retry logic
|
||||
const authTimeoutRef = useRef<NodeJS.Timeout | null>(null); // Timer for auth popup
|
||||
|
||||
// ===== Logging Utility =====
|
||||
|
||||
const addLog = useCallback(
|
||||
(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}`);
|
||||
if (isMountedRef.current) {
|
||||
setLog((prevLog) => [...prevLog, { level, message: fullMessage, timestamp: Date.now() }]);
|
||||
}
|
||||
},
|
||||
[debug],
|
||||
);
|
||||
|
||||
// ===== Core Actions =====
|
||||
|
||||
const disconnect = useCallback(
|
||||
async (quiet = false) => {
|
||||
if (!quiet) addLog('info', 'Disconnecting...');
|
||||
connectingRef.current = false;
|
||||
clearTimeout(authTimeoutRef.current!);
|
||||
authTimeoutRef.current = null;
|
||||
|
||||
const client = clientRef.current;
|
||||
const transport = transportRef.current;
|
||||
clientRef.current = null;
|
||||
transportRef.current = null;
|
||||
|
||||
// Don't reset state if unmounting
|
||||
if (isMountedRef.current && !quiet) {
|
||||
setState('discovering'); // Or 'disconnected'? Let's stick to initial flow for retry.
|
||||
setTools([]);
|
||||
setError(undefined);
|
||||
setAuthUrl(undefined);
|
||||
pendingAuthUrlRef.current = undefined;
|
||||
}
|
||||
|
||||
// Close transport first, then client
|
||||
if (transport) {
|
||||
try {
|
||||
await transport.close();
|
||||
if (!quiet) addLog('debug', 'Transport closed');
|
||||
} catch (err) {
|
||||
if (!quiet) addLog('warn', 'Error closing transport:', err);
|
||||
}
|
||||
}
|
||||
// Note: Client doesn't have an explicit async close, it relies on transport.
|
||||
},
|
||||
[addLog],
|
||||
);
|
||||
|
||||
const failConnection = useCallback((errorMessage: string, connectionError?: Error) => {
|
||||
addLog('error', errorMessage, connectionError ?? '');
|
||||
if (isMountedRef.current) {
|
||||
setState('failed');
|
||||
setError(errorMessage);
|
||||
}
|
||||
connectingRef.current = false;
|
||||
disconnect(true); // Quiet disconnect
|
||||
}, [addLog, disconnect]);
|
||||
|
||||
|
||||
// ===== 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 {
|
||||
addLog('warn', 'Authentication popup was blocked or failed to open.');
|
||||
setAuthUrl(redirectResult.url); // Ensure the manual URL is available
|
||||
failConnection(
|
||||
'Authentication popup blocked. Please allow popups for this site or use the provided link to authenticate manually.',
|
||||
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 () => {
|
||||
if (connectingRef.current) {
|
||||
addLog('debug', 'Connection attempt already in progress, skipping.');
|
||||
return;
|
||||
}
|
||||
if (!isMountedRef.current) return; // Don't connect if unmounted
|
||||
|
||||
connectingRef.current = true;
|
||||
connectAttemptRef.current += 1;
|
||||
setError(undefined);
|
||||
setAuthUrl(undefined);
|
||||
pendingAuthUrlRef.current = undefined;
|
||||
setState('discovering');
|
||||
addLog('info', `Connecting attempt #${connectAttemptRef.current}...`);
|
||||
|
||||
// 1. Initialize Auth Provider
|
||||
if (!authProviderRef.current) {
|
||||
authProviderRef.current = new BrowserOAuthClientProvider(url, {
|
||||
storageKeyPrefix,
|
||||
clientName,
|
||||
clientUri,
|
||||
callbackUrl,
|
||||
});
|
||||
addLog('debug', 'BrowserOAuthClientProvider initialized.');
|
||||
}
|
||||
|
||||
// 2. Initialize MCP Client
|
||||
if (!clientRef.current) {
|
||||
clientRef.current = new Client(
|
||||
{
|
||||
name: clientConfig.name || 'use-mcp-react-client',
|
||||
version: clientConfig.version || '0.1.0', // TODO: Get version from package.json?
|
||||
},
|
||||
{ capabilities: { /* Define client caps if any */ } },
|
||||
);
|
||||
addLog('debug', 'MCP Client initialized.');
|
||||
}
|
||||
|
||||
// 3. Create SSE Transport
|
||||
addLog('info', 'Creating SSE transport...');
|
||||
setState('connecting');
|
||||
const serverUrl = new URL(url); // Ensure it's a URL object
|
||||
|
||||
// Clear previous transport if any
|
||||
if (transportRef.current) {
|
||||
await transportRef.current.close().catch(e => addLog('warn', 'Error closing previous transport:', e));
|
||||
}
|
||||
|
||||
transportRef.current = new SSEClientTransport(serverUrl, {
|
||||
authProvider: authProviderRef.current,
|
||||
// Pass debug flag to transport if it supports it
|
||||
});
|
||||
addLog('debug', 'SSEClientTransport created.');
|
||||
|
||||
// 4. Set up Transport Handlers
|
||||
transportRef.current.onmessage = (message: JSONRPCMessage) => {
|
||||
// @ts-expect-error - SDK type might be union, check message content
|
||||
addLog('debug', `[Transport] Received message: Method=${message.method || 'N/A'} ID=${message.id || 'N/A'}`);
|
||||
// Forward notifications/responses to the client instance
|
||||
clientRef.current?.handleMessage(message);
|
||||
};
|
||||
|
||||
transportRef.current.onerror = (err: Error) => {
|
||||
// Check if the error indicates an auth issue *before* full connection
|
||||
if (err instanceof UnauthorizedError || err.message.includes('Unauthorized') || err.message.includes('401')) {
|
||||
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 = () => {
|
||||
// Only handle close if we weren't explicitly disconnecting or failing
|
||||
if (connectingRef.current || !isMountedRef.current) return;
|
||||
|
||||
addLog('info', 'Transport connection closed.');
|
||||
if (state === 'ready' && autoReconnect) {
|
||||
const delay = typeof autoReconnect === 'number' ? autoReconnect : DEFAULT_RECONNECT_DELAY;
|
||||
addLog('info', `Attempting to reconnect in ${delay}ms...`);
|
||||
setTimeout(() => {
|
||||
// Perform a clean disconnect before reconnecting
|
||||
disconnect().then(() => {
|
||||
if (isMountedRef.current) connect();
|
||||
});
|
||||
}, delay);
|
||||
// Set state to indicate reconnection attempt? Maybe 'connecting'?
|
||||
if (isMountedRef.current) setState('connecting');
|
||||
} else if (state !== '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
|
||||
}
|
||||
}
|
||||
|
||||
// We have metadata, attempt to open the popup
|
||||
await openAuthPopup(); // This sets state to 'authenticating' or 'failed'
|
||||
|
||||
} 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.
|
||||
|
||||
}, [
|
||||
url, storageKeyPrefix, clientName, clientUri, callbackUrl, clientConfig,
|
||||
addLog, failConnection, disconnect, state, autoReconnect, openAuthPopup,
|
||||
// Explicitly list dependencies that should trigger re-connect if changed:
|
||||
// url, storageKeyPrefix, callbackUrl, clientConfig.name, clientConfig.version (?),
|
||||
]);
|
||||
|
||||
|
||||
// ===== Public API Methods =====
|
||||
|
||||
const callTool = useCallback(
|
||||
async (name: string, args?: Record<string, unknown>) => {
|
||||
if (state !== 'ready' || !clientRef.current) {
|
||||
throw new Error(`MCP client is not ready (current state: ${state}). Cannot call tool "${name}".`);
|
||||
}
|
||||
addLog('info', `Calling tool: ${name}`, args);
|
||||
try {
|
||||
const result = await clientRef.current.request(
|
||||
{
|
||||
method: 'tools/call',
|
||||
params: { name, arguments: args },
|
||||
},
|
||||
CallToolResultSchema, // Optional: Validate result against schema
|
||||
);
|
||||
addLog('info', `Tool "${name}" call successful.`);
|
||||
return result.result; // Assuming CallToolResultSchema has a `result` field
|
||||
} catch (err) {
|
||||
addLog('error', `Error calling tool "${name}": ${err instanceof Error ? err.message : String(err)}`);
|
||||
// TODO: Should specific JSON-RPC errors be handled differently?
|
||||
throw err; // Re-throw the error
|
||||
}
|
||||
},
|
||||
[state, addLog],
|
||||
);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (state === 'failed') {
|
||||
addLog('info', 'Manual retry requested...');
|
||||
// Reset attempt counter? Or let connect handle it? Let connect handle it.
|
||||
disconnect().then(() => {
|
||||
if (isMountedRef.current) connect();
|
||||
});
|
||||
} else {
|
||||
addLog('warn', `Retry called but state is not 'failed' (state: ${state}). Ignoring.`);
|
||||
}
|
||||
}, [state, addLog, disconnect, connect]);
|
||||
|
||||
const authenticate = useCallback(async (): Promise<string | undefined> => {
|
||||
addLog('info', 'Manual authentication requested...');
|
||||
if (state === 'failed' && error?.includes('popup blocked')) {
|
||||
// If we failed due to popup blocking, try opening the popup again
|
||||
return openAuthPopup();
|
||||
} else if (!metadataRef.current && (state === 'failed' || state === 'discovering')) {
|
||||
// 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]);
|
||||
|
||||
|
||||
const clearStorage = useCallback(() => {
|
||||
if (authProviderRef.current) {
|
||||
const count = authProviderRef.current.clearStorage();
|
||||
addLog('info', `Cleared ${count} item(s) from localStorage for ${url}.`);
|
||||
// Also clear related state in the hook
|
||||
metadataRef.current = undefined;
|
||||
pendingAuthUrlRef.current = undefined;
|
||||
setAuthUrl(undefined);
|
||||
// Consider if a disconnect/reconnect is needed after clearing storage
|
||||
addLog('info', 'Storage cleared. Recommend disconnecting and reconnecting if currently connected.');
|
||||
disconnect(); // Force disconnect as auth state is gone
|
||||
} else {
|
||||
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]);
|
||||
|
||||
|
||||
// ===== Effects =====
|
||||
|
||||
// Effect for handling auth callback messages from popup
|
||||
useEffect(() => {
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
// Security: Check origin
|
||||
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') {
|
||||
addLog('info', 'Received auth callback message from popup window.', event.data);
|
||||
clearTimeout(authTimeoutRef.current!); // Stop the auth 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) {
|
||||
addLog('info', 'Authentication successful via popup. Reconnecting...');
|
||||
// The callback saved the token. Disconnect and reconnect to use it.
|
||||
disconnect().then(() => {
|
||||
if (isMountedRef.current) connect();
|
||||
});
|
||||
} else {
|
||||
// Auth failed in the popup/callback handler
|
||||
failConnection(`Authentication failed: ${event.data.error || 'Unknown reason.'}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
addLog('debug', 'Auth callback message listener added.');
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', messageHandler);
|
||||
addLog('debug', 'Auth callback message listener removed.');
|
||||
clearTimeout(authTimeoutRef.current!);
|
||||
};
|
||||
}, [addLog, failConnection, disconnect, connect]); // Dependencies: functions that handle success/failure
|
||||
|
||||
// Effect for initial connection and auto-retry
|
||||
useEffect(() => {
|
||||
// Store mount status
|
||||
isMountedRef.current = true;
|
||||
addLog('debug', 'Component mounted, initiating connection.');
|
||||
connectAttemptRef.current = 0; // Reset attempts on mount/URL change
|
||||
connect(); // Initial connection attempt
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
addLog('debug', 'Component unmounting, disconnecting.');
|
||||
disconnect();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
// Reconnect if these core config options change
|
||||
url,
|
||||
storageKeyPrefix,
|
||||
callbackUrl,
|
||||
// clientName, clientUri? Only if DCR is used.
|
||||
// clientConfig.name, clientConfig.version? Usually stable.
|
||||
// connect, disconnect // connect/disconnect functions are stable due to useCallback
|
||||
]);
|
||||
|
||||
// Effect for auto-retry logic
|
||||
useEffect(() => {
|
||||
let retryTimeoutId: NodeJS.Timeout | null = null;
|
||||
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;
|
||||
addLog('info', `Connection failed, auto-retrying in ${delay}ms...`);
|
||||
retryTimeoutId = setTimeout(() => {
|
||||
if (isMountedRef.current && state === 'failed') { // Check state again before retrying
|
||||
retry();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (retryTimeoutId) {
|
||||
clearTimeout(retryTimeoutId);
|
||||
}
|
||||
};
|
||||
}, [state, autoRetry, retry, addLog]); // Depends on state and retry config
|
||||
|
||||
|
||||
// Return the public API
|
||||
return {
|
||||
state,
|
||||
tools,
|
||||
error,
|
||||
log,
|
||||
authUrl,
|
||||
callTool,
|
||||
retry,
|
||||
disconnect,
|
||||
authenticate,
|
||||
clearStorage,
|
||||
};
|
||||
}
|
||||
11
src/utils/assert.ts
Normal file
11
src/utils/assert.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Asserts that a condition is true, throwing an error if not.
|
||||
* Useful for type narrowing.
|
||||
* @param condition The condition to check.
|
||||
* @param message The error message to throw if the condition is false.
|
||||
*/
|
||||
export function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue