Gemini 2.5 refactor
This commit is contained in:
parent
6e83fa8955
commit
414e9e41fb
12 changed files with 1406 additions and 1051 deletions
92
README.md
92
README.md
|
|
@ -1 +1,91 @@
|
|||
# `use-mcp`
|
||||
# use-mcp
|
||||
|
||||
**⚠️ Experimental: This library is new and under active development. Expect breaking changes. Please report all issues to [https://github.com/geelen/use-mcp/issues](https://github.com/geelen/use-mcp/issues).**
|
||||
|
||||
`use-mcp` provides browser-based utilities for connecting to servers implementing the [Model Context Protocol (MCP)](https://github.com/modelcontext/protocol). It simplifies handling connections, managing authentication (including OAuth 2.0 Authorization Code Flow with PKCE suitable for public clients like web apps), and accessing resources like Tools provided by the MCP server.
|
||||
|
||||
This library is designed to be used alongside the Vercel AI SDK (`ai`) or similar libraries, allowing you to easily integrate MCP tools into your LLM application's frontend.
|
||||
|
||||
## Features
|
||||
|
||||
* Connects to MCP servers via Server-Sent Events (SSE).
|
||||
* Handles MCP JSON-RPC messaging.
|
||||
* Implements browser-friendly OAuth 2.0 authentication (Authorization Code with PKCE) using popups and `localStorage`.
|
||||
* Provides framework-agnostic auth utilities.
|
||||
* Includes a React hook (`useMcp`) for easy integration into React applications (via `use-mcp/react`).
|
||||
|
||||
## Installation
|
||||
|
||||
You need to install this library and the official MCP SDK:
|
||||
|
||||
```bash
|
||||
npm install use-mcp @modelcontextprotocol/sdk
|
||||
# or
|
||||
yarn add use-mcp @modelcontextprotocol/sdk
|
||||
# or
|
||||
pnpm add use-mcp @modelcontextprotocol/sdk
|
||||
```
|
||||
|
||||
## Core Exports
|
||||
|
||||
This main package (use-mcp) exports utilities primarily for handling OAuth in the browser:
|
||||
|
||||
* BrowserOAuthClientProvider: An implementation of the OAuthClientProvider interface from @modelcontextprotocol/sdk. It uses localStorage to store client information and tokens and handles the popup-based authorization flow. It's used internally by the useMcp hook but can be used independently if needed.
|
||||
|
||||
* onMcpAuthorization: A function designed to be called on your OAuth callback page (e.g., /oauth/callback). It handles exchanging the authorization code (received from the auth server) for tokens, validating the state parameter, storing tokens using localStorage, and communicating success or failure back to the original application window that initiated the auth flow.
|
||||
|
||||
React Integration (use-mcp/react)
|
||||
|
||||
For React applications, the easiest way to use this library is via the useMcp hook.
|
||||
|
||||
See the React README for detailed usage instructions and examples.
|
||||
Basic OAuth Callback Setup
|
||||
|
||||
You need an endpoint in your application (e.g., /oauth/callback) that the OAuth server redirects the user back to after they approve the authorization request. This endpoint should call onMcpAuthorization.
|
||||
|
||||
Example (Conceptual - using a simple script in HTML or a framework handler):
|
||||
|
||||
```tsx
|
||||
// pages/oauth/callback.js (or similar route file)
|
||||
import { useEffect } from 'react'; // Or framework equivalent
|
||||
import { onMcpAuthorization } from 'use-mcp';
|
||||
|
||||
export default function OAuthCallbackPage() {
|
||||
useEffect(() => {
|
||||
// Extract query parameters from window.location.search
|
||||
const queryParams = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
||||
// Call the handler
|
||||
onMcpAuthorization(queryParams, {
|
||||
// Optional: specify storage key prefix if not using default 'mcp:auth'
|
||||
// storageKeyPrefix: 'my_custom_prefix'
|
||||
}).then(result => {
|
||||
if (!result.success) {
|
||||
// Error already logged and displayed by onMcpAuthorization
|
||||
console.error("OAuth callback failed:", result.error);
|
||||
// Optionally show a persistent error message or redirect
|
||||
}
|
||||
// On success, onMcpAuthorization closes the window or redirects
|
||||
}).catch(err => {
|
||||
// Catch unexpected errors during the handler execution itself
|
||||
console.error("Unexpected error in OAuth callback handler:", err);
|
||||
// Display error in the popup/page
|
||||
document.body.innerHTML = `<h1>Error</h1><p>An unexpected error occurred during authentication.</p><pre>${err.message}</pre>`;
|
||||
});
|
||||
|
||||
}, []); // Run only once on component mount
|
||||
|
||||
// Render a loading state while the exchange happens
|
||||
return <div>Processing authentication...</div>;
|
||||
}
|
||||
```
|
||||
|
||||
Future Plans
|
||||
|
||||
* Support for other frameworks (Vue, Svelte, etc.).
|
||||
* Improved error handling and recovery.
|
||||
* Support for different OAuth flows if necessary.
|
||||
|
||||
Contributions and feedback are welcome!
|
||||
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