Transfer from geelen/use-mcp
This commit is contained in:
parent
d1d7803d9a
commit
de192f4de0
9 changed files with 9512 additions and 136 deletions
233
src/auth/browser-provider.ts
Normal file
233
src/auth/browser-provider.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
// browser-provider.ts
|
||||
import { OAuthClientInformation, OAuthMetadata, OAuthTokens, OAuthClientMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
// Assuming StoredState is defined in ./types.js and includes fields for provider options
|
||||
import { StoredState } from './types.js' // Adjust path if necessary
|
||||
|
||||
/**
|
||||
* Browser-compatible OAuth client provider for MCP using localStorage.
|
||||
*/
|
||||
export class BrowserOAuthClientProvider implements OAuthClientProvider {
|
||||
readonly serverUrl: string
|
||||
readonly storageKeyPrefix: string
|
||||
readonly serverUrlHash: string
|
||||
readonly clientName: string
|
||||
readonly clientUri: string
|
||||
readonly callbackUrl: string
|
||||
|
||||
constructor(
|
||||
serverUrl: string,
|
||||
options: {
|
||||
storageKeyPrefix?: string
|
||||
clientName?: string
|
||||
clientUri?: string
|
||||
callbackUrl?: string
|
||||
} = {},
|
||||
) {
|
||||
this.serverUrl = serverUrl
|
||||
this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth'
|
||||
this.serverUrlHash = this.hashString(serverUrl)
|
||||
this.clientName = options.clientName || 'MCP Browser Client'
|
||||
this.clientUri = options.clientUri || (typeof window !== 'undefined' ? window.location.origin : '')
|
||||
this.callbackUrl =
|
||||
options.callbackUrl ||
|
||||
(typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback')
|
||||
}
|
||||
|
||||
// --- SDK Interface Methods ---
|
||||
|
||||
get redirectUrl(): string {
|
||||
return this.callbackUrl
|
||||
}
|
||||
|
||||
get clientMetadata(): OAuthClientMetadata {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return JSON.parse(data) as OAuthClientInformation
|
||||
} catch (e) {
|
||||
console.warn(`[${this.storageKeyPrefix}] Failed to parse client information:`, e)
|
||||
localStorage.removeItem(key)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: The SDK's auth() function uses this if dynamic registration is needed.
|
||||
// Ensure your OAuthClientInformationFull matches the expected structure if DCR is used.
|
||||
async saveClientInformation(clientInformation: OAuthClientInformation /* | OAuthClientInformationFull */): Promise<void> {
|
||||
const key = this.getKey('client_info')
|
||||
// Cast needed if handling OAuthClientInformationFull specifically
|
||||
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 {
|
||||
// TODO: Add validation
|
||||
return JSON.parse(data) as OAuthTokens
|
||||
} catch (e) {
|
||||
console.warn(`[${this.storageKeyPrefix}] Failed to parse tokens:`, e)
|
||||
localStorage.removeItem(key)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
||||
const key = this.getKey('tokens')
|
||||
localStorage.setItem(key, JSON.stringify(tokens))
|
||||
// Clean up code verifier and last auth URL after successful token save
|
||||
localStorage.removeItem(this.getKey('code_verifier'))
|
||||
localStorage.removeItem(this.getKey('last_auth_url'))
|
||||
}
|
||||
|
||||
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(
|
||||
`[${this.storageKeyPrefix}] Code verifier not found in storage for key ${key}. Auth flow likely corrupted or timed out.`,
|
||||
)
|
||||
}
|
||||
// SDK's auth() retrieves this BEFORE exchanging code. Don't remove it here.
|
||||
// It will be removed in saveTokens on success.
|
||||
return verifier
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the user agent to the authorization URL, storing necessary state.
|
||||
* This now adheres to the SDK's void return type expectation for the interface.
|
||||
* @param authorizationUrl The fully constructed authorization URL from the SDK.
|
||||
*/
|
||||
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
||||
// Generate a unique state parameter for this authorization request
|
||||
const state = crypto.randomUUID()
|
||||
const stateKey = `${this.storageKeyPrefix}:state_${state}`
|
||||
|
||||
// Store context needed by the callback handler, associated with the state param
|
||||
const stateData: StoredState = {
|
||||
serverUrlHash: this.serverUrlHash,
|
||||
expiry: Date.now() + 1000 * 60 * 10, // State expires in 10 minutes
|
||||
// Store provider options needed to reconstruct on callback
|
||||
providerOptions: {
|
||||
serverUrl: this.serverUrl,
|
||||
storageKeyPrefix: this.storageKeyPrefix,
|
||||
clientName: this.clientName,
|
||||
clientUri: this.clientUri,
|
||||
callbackUrl: this.callbackUrl,
|
||||
},
|
||||
}
|
||||
localStorage.setItem(stateKey, JSON.stringify(stateData))
|
||||
|
||||
// Add the state parameter to the URL
|
||||
authorizationUrl.searchParams.set('state', state)
|
||||
const authUrlString = authorizationUrl.toString()
|
||||
|
||||
// Persist the exact auth URL in case the popup fails and manual navigation is needed
|
||||
localStorage.setItem(this.getKey('last_auth_url'), authUrlString)
|
||||
|
||||
// Attempt to open the popup
|
||||
const popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes,status=yes' // Make configurable if needed
|
||||
try {
|
||||
const popup = window.open(authUrlString, `mcp_auth_${this.serverUrlHash}`, popupFeatures)
|
||||
|
||||
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
|
||||
console.warn(
|
||||
`[${this.storageKeyPrefix}] Popup likely blocked by browser. Manual navigation might be required using the stored URL.`,
|
||||
)
|
||||
// Cannot signal failure back via SDK auth() directly.
|
||||
// useMcp will need to rely on timeout or manual trigger if stuck.
|
||||
} else {
|
||||
popup.focus()
|
||||
console.info(`[${this.storageKeyPrefix}] Redirecting to authorization URL in popup.`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[${this.storageKeyPrefix}] Error opening popup window:`, e)
|
||||
// Cannot signal failure back via SDK auth() directly.
|
||||
}
|
||||
// Regardless of popup success, the interface expects this method to initiate the redirect.
|
||||
// If the popup failed, the user journey stops here until manual action or timeout.
|
||||
}
|
||||
|
||||
// --- Helper Methods ---
|
||||
|
||||
/**
|
||||
* Retrieves the last URL passed to `redirectToAuthorization`. Useful for manual fallback.
|
||||
*/
|
||||
getLastAttemptedAuthUrl(): string | null {
|
||||
return localStorage.getItem(this.getKey('last_auth_url'))
|
||||
}
|
||||
|
||||
clearStorage(): number {
|
||||
const prefixPattern = `${this.storageKeyPrefix}_${this.serverUrlHash}_`
|
||||
const statePattern = `${this.storageKeyPrefix}:state_`
|
||||
const keysToRemove: string[] = []
|
||||
let count = 0
|
||||
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i)
|
||||
if (!key) continue
|
||||
|
||||
if (key.startsWith(prefixPattern)) {
|
||||
keysToRemove.push(key)
|
||||
} else if (key.startsWith(statePattern)) {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
if (item) {
|
||||
// Check if state belongs to this provider instance based on serverUrlHash
|
||||
// We need to parse cautiously as the structure isn't guaranteed.
|
||||
const state = JSON.parse(item) as Partial<StoredState>
|
||||
if (state.serverUrlHash === this.serverUrlHash) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[${this.storageKeyPrefix}] Error parsing state key ${key} during clearStorage:`, e)
|
||||
// Optionally remove malformed keys
|
||||
// keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueKeysToRemove = [...new Set(keysToRemove)]
|
||||
uniqueKeysToRemove.forEach((key) => {
|
||||
localStorage.removeItem(key)
|
||||
count++
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
private hashString(str: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash
|
||||
}
|
||||
return Math.abs(hash).toString(16)
|
||||
}
|
||||
|
||||
getKey(keySuffix: string): string {
|
||||
return `${this.storageKeyPrefix}_${this.serverUrlHash}_${keySuffix}`
|
||||
}
|
||||
}
|
||||
128
src/auth/callback.ts
Normal file
128
src/auth/callback.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// callback.ts
|
||||
import { auth } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { BrowserOAuthClientProvider } from './browser-provider.js' // Adjust path
|
||||
import { StoredState } from './types.js' // Adjust path, ensure definition includes providerOptions
|
||||
|
||||
/**
|
||||
* Handles the OAuth callback using the SDK's auth() function.
|
||||
* Assumes it's running on the page specified as the callbackUrl.
|
||||
*/
|
||||
export async function onMcpAuthorization() {
|
||||
const queryParams = new URLSearchParams(window.location.search)
|
||||
const code = queryParams.get('code')
|
||||
const state = queryParams.get('state')
|
||||
const error = queryParams.get('error')
|
||||
const errorDescription = queryParams.get('error_description')
|
||||
|
||||
const logPrefix = '[mcp-callback]' // Generic prefix, or derive from stored state later
|
||||
console.log(`${logPrefix} Handling callback...`, { code, state, error, errorDescription })
|
||||
|
||||
let provider: BrowserOAuthClientProvider | null = null
|
||||
let storedStateData: StoredState | null = null
|
||||
const stateKey = state ? `mcp:auth:state_${state}` : null // Reconstruct state key prefix assumption
|
||||
|
||||
try {
|
||||
// --- Basic Error Handling ---
|
||||
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 || !stateKey) {
|
||||
throw new Error('State parameter not found or invalid in callback query parameters.')
|
||||
}
|
||||
|
||||
// --- Retrieve Stored State & Provider Options ---
|
||||
const storedStateJSON = localStorage.getItem(stateKey)
|
||||
if (!storedStateJSON) {
|
||||
throw new Error(`Invalid or expired state parameter "${state}". No matching state found in storage.`)
|
||||
}
|
||||
try {
|
||||
storedStateData = JSON.parse(storedStateJSON) as StoredState
|
||||
} catch (e) {
|
||||
throw new Error('Failed to parse stored OAuth state.')
|
||||
}
|
||||
|
||||
// Validate expiry
|
||||
if (!storedStateData.expiry || storedStateData.expiry < Date.now()) {
|
||||
localStorage.removeItem(stateKey) // Clean up expired state
|
||||
throw new Error('OAuth state has expired. Please try initiating authentication again.')
|
||||
}
|
||||
|
||||
// Ensure provider options are present
|
||||
if (!storedStateData.providerOptions) {
|
||||
throw new Error('Stored state is missing required provider options.')
|
||||
}
|
||||
const { serverUrl, ...providerOptions } = storedStateData.providerOptions
|
||||
|
||||
// --- Instantiate Provider ---
|
||||
console.log(`${logPrefix} Re-instantiating provider for server: ${serverUrl}`)
|
||||
provider = new BrowserOAuthClientProvider(serverUrl, providerOptions)
|
||||
|
||||
// --- Call SDK Auth Function ---
|
||||
console.log(`${logPrefix} Calling SDK auth() to exchange code...`)
|
||||
// The SDK auth() function will internally:
|
||||
// 1. Use provider.clientInformation()
|
||||
// 2. Use provider.codeVerifier()
|
||||
// 3. Call exchangeAuthorization()
|
||||
// 4. Use provider.saveTokens() on success
|
||||
const authResult = await auth(provider, { serverUrl, authorizationCode: code })
|
||||
|
||||
if (authResult === 'AUTHORIZED') {
|
||||
console.log(`${logPrefix} Authorization successful via SDK auth(). Notifying opener...`)
|
||||
// --- Notify Opener and Close (Success) ---
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage({ type: 'mcp_auth_callback', success: true }, window.location.origin)
|
||||
window.close()
|
||||
} else {
|
||||
console.warn(`${logPrefix} No opener window detected. Redirecting to root.`)
|
||||
window.location.href = '/' // Or a configured post-auth destination
|
||||
}
|
||||
// Clean up state ONLY on success and after notifying opener
|
||||
localStorage.removeItem(stateKey)
|
||||
} else {
|
||||
// This case shouldn't happen if `authorizationCode` is provided to `auth()`
|
||||
console.warn(`${logPrefix} SDK auth() returned unexpected status: ${authResult}`)
|
||||
throw new Error(`Unexpected result from authentication library: ${authResult}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`${logPrefix} Error during OAuth callback handling:`, err)
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
|
||||
// --- Notify Opener and Display Error (Failure) ---
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage({ type: 'mcp_auth_callback', success: false, error: errorMessage }, window.location.origin)
|
||||
// Optionally close even on error, depending on UX preference
|
||||
// window.close();
|
||||
}
|
||||
|
||||
// Display error in the callback window
|
||||
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 or <a href="#" onclick="window.close(); return false;">click here to close</a>.</p>
|
||||
<pre style="font-size: 0.8em; color: #555; margin-top: 20px; white-space: pre-wrap;">${
|
||||
err instanceof Error ? err.stack : ''
|
||||
}</pre>
|
||||
</div>
|
||||
`
|
||||
} catch (displayError) {
|
||||
console.error(`${logPrefix} Could not display error in callback window:`, displayError)
|
||||
}
|
||||
// Clean up potentially invalid state on error
|
||||
if (stateKey) {
|
||||
localStorage.removeItem(stateKey)
|
||||
}
|
||||
// Clean up potentially dangling verifier or last_auth_url if auth failed badly
|
||||
// Note: saveTokens should clean these on success
|
||||
if (provider) {
|
||||
localStorage.removeItem(provider.getKey('code_verifier'))
|
||||
localStorage.removeItem(provider.getKey('last_auth_url'))
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/auth/types.ts
Normal file
19
src/auth/types.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js'
|
||||
|
||||
/**
|
||||
* Internal type for storing OAuth state in localStorage during the popup flow.
|
||||
* @internal
|
||||
*/
|
||||
export interface StoredState {
|
||||
expiry: number
|
||||
metadata?: OAuthMetadata // Optional: might not be needed if auth() rediscovers
|
||||
serverUrlHash: string
|
||||
// Add provider options needed on callback:
|
||||
providerOptions: {
|
||||
serverUrl: string
|
||||
storageKeyPrefix: string
|
||||
clientName: string
|
||||
clientUri: string
|
||||
callbackUrl: string
|
||||
}
|
||||
}
|
||||
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';
|
||||
178
src/react/README.md
Normal file
178
src/react/README.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# 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'
|
||||
// Optional: Import types if needed
|
||||
import type { UseMcpOptions, UseMcpResult, Tool } from 'use-mcp'
|
||||
```
|
||||
|
||||
## 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.
|
||||
10
src/react/index.ts
Normal file
10
src/react/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Entry point for the use-mcp React integration.
|
||||
* Provides the useMcp hook and related types.
|
||||
*/
|
||||
|
||||
export { useMcp } from './useMcp.js'
|
||||
export type { UseMcpOptions, UseMcpResult } from './types.js'
|
||||
|
||||
// Re-export core Tool type for convenience when using hook result?
|
||||
export type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
74
src/react/types.ts
Normal file
74
src/react/types.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
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
|
||||
}
|
||||
/** Custom headers that can be used to bypass auth */
|
||||
customHeaders?: Record<string, 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: () => void
|
||||
/** Clears all stored authentication data (tokens, client info, etc.) for this server URL from localStorage. */
|
||||
clearStorage: () => void
|
||||
}
|
||||
618
src/react/useMcp.ts
Normal file
618
src/react/useMcp.ts
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
// useMcp.ts
|
||||
import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
// Import both transport types
|
||||
import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'
|
||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' // Added
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { auth, UnauthorizedError, OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'
|
||||
import { BrowserOAuthClientProvider } from '../auth/browser-provider.js' // Adjust path
|
||||
import { assert } from '../utils/assert.js' // Adjust path
|
||||
import type { UseMcpOptions, UseMcpResult } from './types.js' // Adjust path
|
||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' // Added for type safety
|
||||
|
||||
const DEFAULT_RECONNECT_DELAY = 3000
|
||||
const DEFAULT_RETRY_DELAY = 5000
|
||||
const AUTH_TIMEOUT = 5 * 60 * 1000
|
||||
|
||||
// Define Transport types literal for clarity
|
||||
type TransportType = 'http' | 'sse'
|
||||
|
||||
export function useMcp(options: UseMcpOptions): UseMcpResult {
|
||||
const {
|
||||
url,
|
||||
clientName,
|
||||
clientUri,
|
||||
callbackUrl = typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback',
|
||||
storageKeyPrefix = 'mcp:auth',
|
||||
clientConfig = {},
|
||||
customHeaders = {},
|
||||
debug = false,
|
||||
autoRetry = false,
|
||||
autoReconnect = DEFAULT_RECONNECT_DELAY,
|
||||
} = 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)
|
||||
|
||||
const clientRef = useRef<Client | null>(null)
|
||||
// Transport ref can hold either type now
|
||||
const transportRef = useRef<Transport | null>(null)
|
||||
const authProviderRef = useRef<BrowserOAuthClientProvider | null>(null)
|
||||
const connectingRef = useRef<boolean>(false)
|
||||
const isMountedRef = useRef<boolean>(true)
|
||||
const connectAttemptRef = useRef<number>(0)
|
||||
const authTimeoutRef = useRef<number | null>(null)
|
||||
|
||||
// --- Refs for values used in callbacks ---
|
||||
const stateRef = useRef(state)
|
||||
const autoReconnectRef = useRef(autoReconnect)
|
||||
// Ref to store the type of transport that successfully connected
|
||||
const successfulTransportRef = useRef<TransportType | null>(null)
|
||||
|
||||
// --- Effect to keep refs updated ---
|
||||
useEffect(() => {
|
||||
stateRef.current = state
|
||||
autoReconnectRef.current = autoReconnect
|
||||
}, [state, autoReconnect])
|
||||
|
||||
// --- Stable Callbacks ---
|
||||
// addLog is stable (empty dependency array)
|
||||
const addLog = useCallback(
|
||||
(level: UseMcpResult['log'][0]['level'], message: string, ...args: unknown[]) => {
|
||||
// if (level === 'debug' && !debug) return; // Uncomment if using debug flag
|
||||
const fullMessage = args.length > 0 ? `${message} ${args.map((arg) => JSON.stringify(arg)).join(' ')}` : message
|
||||
console[level](`[useMcp] ${fullMessage}`)
|
||||
// Use isMountedRef to prevent state updates after unmount
|
||||
if (isMountedRef.current) {
|
||||
setLog((prevLog) => [...prevLog.slice(-100), { level, message: fullMessage, timestamp: Date.now() }])
|
||||
}
|
||||
},
|
||||
[], // Empty dependency array makes this stable
|
||||
)
|
||||
|
||||
// disconnect is stable (depends only on stable addLog)
|
||||
const disconnect = useCallback(
|
||||
async (quiet = false) => {
|
||||
if (!quiet) addLog('info', 'Disconnecting...')
|
||||
connectingRef.current = false
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
authTimeoutRef.current = null
|
||||
|
||||
const transport = transportRef.current
|
||||
clientRef.current = null // Ensure client is cleared
|
||||
transportRef.current = null // Ensure transport is cleared
|
||||
|
||||
// Only reset state if mounted and not a quiet disconnect
|
||||
if (isMountedRef.current && !quiet) {
|
||||
setState('discovering')
|
||||
setTools([])
|
||||
setError(undefined)
|
||||
setAuthUrl(undefined)
|
||||
}
|
||||
|
||||
if (transport) {
|
||||
try {
|
||||
await transport.close()
|
||||
if (!quiet) addLog('debug', 'Transport closed')
|
||||
} catch (err) {
|
||||
if (!quiet) addLog('warn', 'Error closing transport:', err)
|
||||
}
|
||||
}
|
||||
},
|
||||
[addLog], // Depends only on stable addLog
|
||||
)
|
||||
|
||||
// failConnection is stable (depends only on stable addLog)
|
||||
const failConnection = useCallback(
|
||||
(errorMessage: string, connectionError?: Error) => {
|
||||
addLog('error', errorMessage, connectionError ?? '')
|
||||
if (isMountedRef.current) {
|
||||
setState('failed') // Set state to failed
|
||||
setError(errorMessage)
|
||||
const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl()
|
||||
if (manualUrl) {
|
||||
setAuthUrl(manualUrl)
|
||||
addLog('info', 'Manual authentication URL may be available.', manualUrl)
|
||||
}
|
||||
}
|
||||
connectingRef.current = false // Ensure connection attempt is marked as finished
|
||||
// Do not call disconnect here - allow user to see error and retry
|
||||
},
|
||||
[addLog],
|
||||
) // Depends only on stable addLog
|
||||
|
||||
// connect needs to be stable. Remove state/autoReconnect deps, use refs inside.
|
||||
const connect = useCallback(async () => {
|
||||
// Prevent concurrent connections
|
||||
if (connectingRef.current) {
|
||||
addLog('debug', 'Connection attempt already in progress.')
|
||||
return
|
||||
}
|
||||
if (!isMountedRef.current) {
|
||||
addLog('debug', 'Connect called after unmount, aborting.')
|
||||
return
|
||||
}
|
||||
|
||||
connectingRef.current = true // Mark start of connection sequence
|
||||
connectAttemptRef.current += 1
|
||||
setError(undefined)
|
||||
setAuthUrl(undefined)
|
||||
successfulTransportRef.current = null // Reset successful transport type
|
||||
setState('discovering')
|
||||
addLog('info', `Connecting attempt #${connectAttemptRef.current} to ${url}...`)
|
||||
|
||||
// Initialize provider/client if needed (idempotent)
|
||||
// Ensure provider/client are initialized (idempotent check)
|
||||
if (!authProviderRef.current) {
|
||||
authProviderRef.current = new BrowserOAuthClientProvider(url, {
|
||||
storageKeyPrefix,
|
||||
clientName,
|
||||
clientUri,
|
||||
callbackUrl,
|
||||
})
|
||||
addLog('debug', 'BrowserOAuthClientProvider initialized in connect.')
|
||||
}
|
||||
if (!clientRef.current) {
|
||||
clientRef.current = new Client(
|
||||
{ name: clientConfig.name || 'use-mcp-react-client', version: clientConfig.version || '0.1.0' },
|
||||
{ capabilities: {} },
|
||||
)
|
||||
addLog('debug', 'MCP Client initialized in connect.')
|
||||
}
|
||||
|
||||
// --- Helper function for a single connection attempt ---
|
||||
const tryConnectWithTransport = async (transportType: TransportType): Promise<'success' | 'fallback' | 'auth_redirect' | 'failed'> => {
|
||||
addLog('info', `Attempting connection with ${transportType.toUpperCase()} transport...`)
|
||||
// Ensure state reflects current attempt phase, unless already authenticating
|
||||
if (stateRef.current !== 'authenticating') {
|
||||
setState('connecting')
|
||||
}
|
||||
|
||||
let transportInstance: Transport // Use base Transport type
|
||||
|
||||
// 1. Create Transport Instance & Close Previous
|
||||
try {
|
||||
assert(authProviderRef.current, 'Auth Provider must be initialized')
|
||||
assert(clientRef.current, 'Client must be initialized')
|
||||
|
||||
// Close existing transport before creating new one
|
||||
if (transportRef.current) {
|
||||
await transportRef.current.close().catch((e) => addLog('warn', `Error closing previous transport: ${e.message}`))
|
||||
transportRef.current = null
|
||||
}
|
||||
|
||||
const commonOptions: SSEClientTransportOptions = {
|
||||
authProvider: authProviderRef.current,
|
||||
requestInit: { headers: customHeaders },
|
||||
}
|
||||
const targetUrl = new URL(url)
|
||||
|
||||
if (transportType === 'http') {
|
||||
transportInstance = new StreamableHTTPClientTransport(targetUrl, commonOptions)
|
||||
} else {
|
||||
// sse
|
||||
transportInstance = new SSEClientTransport(targetUrl, commonOptions)
|
||||
}
|
||||
transportRef.current = transportInstance // Assign to ref immediately
|
||||
addLog('debug', `${transportType.toUpperCase()} transport created.`)
|
||||
} catch (err) {
|
||||
// Use stable failConnection
|
||||
failConnection(
|
||||
`Failed to create ${transportType.toUpperCase()} transport: ${err instanceof Error ? err.message : String(err)}`,
|
||||
err instanceof Error ? err : undefined,
|
||||
)
|
||||
return 'failed' // Indicate definitive failure
|
||||
}
|
||||
|
||||
// 2. Setup Handlers for the new transportInstance
|
||||
transportInstance.onmessage = (message: JSONRPCMessage) => {
|
||||
// Use stable addLog
|
||||
addLog('debug', `[Transport] Received: ${JSON.stringify(message)}`)
|
||||
// @ts-ignore
|
||||
clientRef.current?.handleMessage(message) // Forward to current client
|
||||
}
|
||||
transportInstance.onerror = (err: Error) => {
|
||||
// Transport errors usually mean connection is lost/failed definitively for this transport
|
||||
// Use stable failConnection
|
||||
failConnection(`Transport error (${transportType.toUpperCase()}): ${err.message}`, err)
|
||||
// Should we return 'failed' here? failConnection sets state, maybe that's enough.
|
||||
}
|
||||
transportInstance.onclose = () => {
|
||||
// Use refs for checks and stable callbacks
|
||||
if (!isMountedRef.current || connectingRef.current) return // Ignore if connecting/unmounted
|
||||
|
||||
addLog('info', `Transport connection closed (${successfulTransportRef.current || 'unknown'} type).`)
|
||||
const currentState = stateRef.current
|
||||
const currentAutoReconnect = autoReconnectRef.current
|
||||
|
||||
if (currentState === 'ready' && currentAutoReconnect) {
|
||||
const delay = typeof currentAutoReconnect === 'number' ? currentAutoReconnect : DEFAULT_RECONNECT_DELAY
|
||||
addLog('info', `Attempting to reconnect in ${delay}ms...`)
|
||||
setState('connecting')
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
connect() // Start full connect logic again (will default to HTTP)
|
||||
}
|
||||
}, delay)
|
||||
} else if (currentState !== 'failed' && currentState !== 'authenticating') {
|
||||
// Use stable failConnection
|
||||
failConnection('Connection closed unexpectedly.')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Attempt client.connect()
|
||||
try {
|
||||
addLog('info', `Connecting client via ${transportType.toUpperCase()}...`)
|
||||
await clientRef.current!.connect(transportInstance)
|
||||
|
||||
// --- Success Path ---
|
||||
addLog('info', `Client connected via ${transportType.toUpperCase()}. Loading tools...`)
|
||||
successfulTransportRef.current = transportType // Store successful type
|
||||
setState('loading')
|
||||
|
||||
const toolsResponse = await clientRef.current!.request({ method: 'tools/list' }, ListToolsResultSchema)
|
||||
|
||||
if (isMountedRef.current) {
|
||||
// Check mount before final state updates
|
||||
setTools(toolsResponse.tools)
|
||||
addLog('info', `Loaded ${toolsResponse.tools.length} tools.`)
|
||||
setState('ready') // Final success state
|
||||
// connectingRef will be set to false after orchestration logic
|
||||
connectAttemptRef.current = 0 // Reset on success
|
||||
return 'success'
|
||||
} else {
|
||||
return 'failed' // Failed due to unmount after connect but before ready
|
||||
}
|
||||
} catch (connectErr) {
|
||||
// --- Error Handling Path ---
|
||||
addLog('debug', `Client connect error via ${transportType.toUpperCase()}:`, connectErr)
|
||||
const errorInstance = connectErr instanceof Error ? connectErr : new Error(String(connectErr))
|
||||
|
||||
// Check for 404/405 specifically for HTTP transport
|
||||
const errorMessage = errorInstance.message
|
||||
const is404 = errorMessage.includes('404') || errorMessage.includes('Not Found')
|
||||
const is405 = errorMessage.includes('405') || errorMessage.includes('Method Not Allowed')
|
||||
const isLikelyCors =
|
||||
errorMessage === 'Failed to fetch' /* Chrome */ ||
|
||||
errorMessage === 'NetworkError when attempting to fetch resource.' /* Firefox */ ||
|
||||
errorMessage === 'Load failed' /* Safari */
|
||||
|
||||
if (transportType === 'http' && (is404 || is405 || isLikelyCors)) {
|
||||
addLog('warn', `HTTP transport failed (${isLikelyCors ? 'CORS' : is404 ? '404' : '405'}). Will attempt fallback to SSE.`)
|
||||
return 'fallback' // Signal that fallback should be attempted
|
||||
}
|
||||
|
||||
// Check for Auth error (Simplified - requires more thought for interaction with fallback)
|
||||
if (errorInstance instanceof UnauthorizedError || errorMessage.includes('Unauthorized') || errorMessage.includes('401')) {
|
||||
addLog('info', 'Authentication required. Initiating SDK auth flow...')
|
||||
// Ensure state is set only once if multiple attempts trigger auth
|
||||
if (stateRef.current !== 'authenticating') {
|
||||
setState('authenticating')
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
authTimeoutRef.current = setTimeout(() => {
|
||||
/* ... timeout logic ... */
|
||||
}, AUTH_TIMEOUT)
|
||||
}
|
||||
|
||||
try {
|
||||
assert(authProviderRef.current, 'Auth Provider not available for auth flow')
|
||||
const authResult = await auth(authProviderRef.current, { serverUrl: url })
|
||||
|
||||
if (!isMountedRef.current) return 'failed' // Unmounted during auth
|
||||
|
||||
if (authResult === 'AUTHORIZED') {
|
||||
addLog('info', 'Authentication successful via existing token or refresh. Re-attempting connection...')
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
// Re-trigger the *entire* connect sequence after successful auth
|
||||
// It will start with HTTP again.
|
||||
// We return 'failed' here to stop current sequence, connect() below will handle restart.
|
||||
// Set connectingRef false so outer connect call can proceed
|
||||
connectingRef.current = false
|
||||
connect() // Restart full connection sequence
|
||||
return 'failed' // Stop this attempt sequence, new one started
|
||||
} else if (authResult === 'REDIRECT') {
|
||||
addLog('info', 'Redirecting for authentication. Waiting for callback...')
|
||||
return 'auth_redirect' // Signal that we are waiting for redirect
|
||||
}
|
||||
} catch (sdkAuthError) {
|
||||
if (!isMountedRef.current) return 'failed'
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
addLog('error', 'Auth flow failed:', sdkAuthError)
|
||||
// Auth failed, but still allow fallback to SSE
|
||||
if (transportType === 'http') {
|
||||
return 'fallback' // Try SSE even after auth failure
|
||||
} else {
|
||||
failConnection(
|
||||
`Failed to initiate authentication: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`,
|
||||
sdkAuthError instanceof Error ? sdkAuthError : undefined,
|
||||
)
|
||||
return 'failed' // Auth initiation failed for SSE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle other connection errors
|
||||
// Don't call failConnection here for HTTP transport - let orchestration handle it
|
||||
// so that SSE fallback can still be attempted
|
||||
if (transportType === 'http') {
|
||||
addLog('warn', `HTTP transport failed: ${errorMessage}. Will attempt fallback to SSE.`)
|
||||
return 'fallback'
|
||||
} else {
|
||||
// For SSE transport, we can fail immediately since there's no further fallback
|
||||
failConnection(`Failed to connect via ${transportType.toUpperCase()}: ${errorMessage}`, errorInstance)
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
} // End of tryConnectWithTransport helper
|
||||
|
||||
// --- Orchestrate Connection Attempts ---
|
||||
let finalStatus: 'success' | 'auth_redirect' | 'failed' | 'fallback' = 'failed' // Default to failed
|
||||
|
||||
// 1. Try HTTP
|
||||
const httpResult = await tryConnectWithTransport('http')
|
||||
|
||||
// 2. Try SSE only if HTTP requested fallback and we haven't redirected
|
||||
if (httpResult === 'fallback' && isMountedRef.current && stateRef.current !== 'authenticating') {
|
||||
const sseResult = await tryConnectWithTransport('sse')
|
||||
finalStatus = sseResult // Use SSE result as final status
|
||||
} else {
|
||||
finalStatus = httpResult // Use HTTP result if no fallback was needed/possible
|
||||
}
|
||||
|
||||
// If we still have 'fallback' status, convert to 'failed' since no more transports to try
|
||||
if (finalStatus === 'fallback') {
|
||||
finalStatus = 'failed'
|
||||
failConnection('All transport methods failed')
|
||||
}
|
||||
|
||||
// --- Finalize Connection State ---
|
||||
// Set connectingRef based on the final outcome.
|
||||
// It should be false if 'success' or 'failed'.
|
||||
// It should remain true if 'auth_redirect'.
|
||||
if (finalStatus === 'success' || finalStatus === 'failed') {
|
||||
connectingRef.current = false
|
||||
}
|
||||
// If finalStatus is 'auth_redirect', connectingRef remains true (set at the start).
|
||||
|
||||
addLog('debug', `Connection sequence finished with status: ${finalStatus}`)
|
||||
}, [
|
||||
// Stable callback dependencies
|
||||
addLog,
|
||||
failConnection,
|
||||
disconnect,
|
||||
auth, // Include SDK auth function if used directly
|
||||
// Configuration dependencies
|
||||
url,
|
||||
storageKeyPrefix,
|
||||
clientName,
|
||||
clientUri,
|
||||
callbackUrl,
|
||||
clientConfig.name,
|
||||
clientConfig.version,
|
||||
// No state/autoReconnect dependency here
|
||||
])
|
||||
|
||||
// callTool is stable (depends on stable addLog, failConnection, connect, and URL)
|
||||
const callTool = useCallback(
|
||||
async (name: string, args?: Record<string, unknown>) => {
|
||||
// Use stateRef for check, state for throwing error message
|
||||
if (stateRef.current !== 'ready' || !clientRef.current) {
|
||||
throw new Error(`MCP client is not ready (current state: ${state}). Cannot call tool "${name}".`)
|
||||
}
|
||||
addLog('info', `Calling tool: ${name}`, args)
|
||||
try {
|
||||
const result = await clientRef.current.request({ method: 'tools/call', params: { name, arguments: args } }, CallToolResultSchema)
|
||||
addLog('info', `Tool "${name}" call successful:`, result)
|
||||
return result
|
||||
} catch (err) {
|
||||
addLog('error', `Error calling tool "${name}": ${err instanceof Error ? err.message : String(err)}`, err)
|
||||
const errorInstance = err instanceof Error ? err : new Error(String(err))
|
||||
|
||||
if (
|
||||
errorInstance instanceof UnauthorizedError ||
|
||||
errorInstance.message.includes('Unauthorized') ||
|
||||
errorInstance.message.includes('401')
|
||||
) {
|
||||
addLog('warn', 'Tool call unauthorized, attempting re-authentication...')
|
||||
setState('authenticating') // Update UI state
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current) // Reset timeout
|
||||
authTimeoutRef.current = setTimeout(() => {
|
||||
/* ... timeout logic ... */
|
||||
}, AUTH_TIMEOUT)
|
||||
|
||||
try {
|
||||
assert(authProviderRef.current, 'Auth Provider not available for tool re-auth')
|
||||
const authResult = await auth(authProviderRef.current, { serverUrl: url })
|
||||
|
||||
if (!isMountedRef.current) return // Check mount
|
||||
|
||||
if (authResult === 'AUTHORIZED') {
|
||||
addLog('info', 'Re-authentication successful. Retrying tool call is recommended, or reconnecting.')
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
// Option 1: Just set state ready and let user retry tool?
|
||||
// setState('ready');
|
||||
// Option 2: Reconnect client completely? Safer.
|
||||
connectingRef.current = false
|
||||
connect() // Reconnect session
|
||||
} else if (authResult === 'REDIRECT') {
|
||||
addLog('info', 'Redirecting for re-authentication for tool call.')
|
||||
// State is authenticating, wait for callback
|
||||
}
|
||||
} catch (sdkAuthError) {
|
||||
if (!isMountedRef.current) return
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
failConnection(
|
||||
`Re-authentication failed: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`,
|
||||
sdkAuthError instanceof Error ? sdkAuthError : undefined,
|
||||
)
|
||||
}
|
||||
}
|
||||
// Re-throw original error unless handled by re-auth redirect
|
||||
// @ts-ignore
|
||||
if (stateRef.current !== 'authenticating') {
|
||||
// Only re-throw if not waiting for redirect
|
||||
throw err
|
||||
}
|
||||
// If authenticating, we might want to signal the caller differently,
|
||||
// but for now, we don't re-throw, assuming the UI will react to the 'authenticating' state.
|
||||
return undefined // Or indicate auth required?
|
||||
}
|
||||
},
|
||||
[state, url, addLog, failConnection, connect], // Depends on state for error message, url, and stable callbacks
|
||||
)
|
||||
|
||||
// retry is stable (depends on stable addLog, connect)
|
||||
const retry = useCallback(() => {
|
||||
// Use stateRef for check
|
||||
if (stateRef.current === 'failed') {
|
||||
addLog('info', 'Retry requested...')
|
||||
// connect() will handle resetting state and error internally
|
||||
connect()
|
||||
} else {
|
||||
addLog('warn', `Retry called but state is not 'failed' (state: ${stateRef.current}). Ignoring.`)
|
||||
}
|
||||
}, [addLog, connect]) // Depends only on stable callbacks
|
||||
|
||||
// authenticate is stable (depends on stable addLog, retry, connect)
|
||||
const authenticate = useCallback(() => {
|
||||
addLog('info', 'Manual authentication requested...')
|
||||
const currentState = stateRef.current // Use ref
|
||||
|
||||
if (currentState === 'failed') {
|
||||
addLog('info', 'Attempting to reconnect and authenticate via retry...')
|
||||
retry()
|
||||
} else if (currentState === 'authenticating') {
|
||||
addLog('warn', 'Already attempting authentication. Check for blocked popups or wait for timeout.')
|
||||
const manualUrl = authProviderRef.current?.getLastAttemptedAuthUrl()
|
||||
if (manualUrl && !authUrl) {
|
||||
// Use component state `authUrl` here
|
||||
setAuthUrl(manualUrl)
|
||||
addLog('info', 'Manual authentication URL retrieved:', manualUrl)
|
||||
}
|
||||
} else {
|
||||
addLog(
|
||||
'info',
|
||||
`Client not in a state requiring manual authentication trigger (state: ${currentState}). If needed, try disconnecting and reconnecting.`,
|
||||
)
|
||||
// Optionally, force re-auth even if ready?
|
||||
// addLog('info', 'Forcing re-authentication...');
|
||||
// setState('authenticating');
|
||||
// assert(authProviderRef.current, "Auth Provider not available");
|
||||
// auth(authProviderRef.current, { serverUrl: url }).catch(failConnection);
|
||||
}
|
||||
}, [addLog, retry, authUrl]) // Depends on stable callbacks and authUrl state
|
||||
|
||||
// clearStorage is stable (depends on stable addLog, disconnect)
|
||||
const clearStorage = useCallback(() => {
|
||||
if (authProviderRef.current) {
|
||||
const count = authProviderRef.current.clearStorage()
|
||||
addLog('info', `Cleared ${count} item(s) from localStorage for ${url}.`)
|
||||
setAuthUrl(undefined) // Clear manual URL state
|
||||
// Disconnect should reset state appropriately
|
||||
disconnect()
|
||||
} else {
|
||||
addLog('warn', 'Auth provider not initialized, cannot clear storage.')
|
||||
}
|
||||
}, [url, addLog, disconnect]) // Depends on url and stable callbacks
|
||||
|
||||
// ===== Effects =====
|
||||
|
||||
// Effect for handling auth callback messages from popup (Stable dependencies)
|
||||
useEffect(() => {
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) return
|
||||
if (event.data?.type === 'mcp_auth_callback') {
|
||||
addLog('info', 'Received auth callback message.', event.data)
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
|
||||
if (event.data.success) {
|
||||
addLog('info', 'Authentication successful via popup. Reconnecting client...')
|
||||
connectingRef.current = false
|
||||
connect() // Call stable connect
|
||||
} else {
|
||||
failConnection(`Authentication failed in callback: ${event.data.error || 'Unknown reason.'}`) // Call stable failConnection
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', messageHandler)
|
||||
addLog('debug', 'Auth callback message listener added.')
|
||||
return () => {
|
||||
window.removeEventListener('message', messageHandler)
|
||||
addLog('debug', 'Auth callback message listener removed.')
|
||||
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
|
||||
}
|
||||
// Dependencies are stable callbacks
|
||||
}, [addLog, failConnection, connect])
|
||||
|
||||
// Initial Connection (depends on config and stable callbacks)
|
||||
useEffect(() => {
|
||||
/* ... as before, calls stable connect/disconnect ... */
|
||||
isMountedRef.current = true
|
||||
addLog('debug', 'useMcp mounted, initiating connection.')
|
||||
connectAttemptRef.current = 0
|
||||
// Initialize provider here if needed
|
||||
if (!authProviderRef.current || authProviderRef.current.serverUrl !== url) {
|
||||
authProviderRef.current = new BrowserOAuthClientProvider(url, {
|
||||
storageKeyPrefix,
|
||||
clientName,
|
||||
clientUri,
|
||||
callbackUrl,
|
||||
})
|
||||
addLog('debug', 'BrowserOAuthClientProvider initialized/updated on mount/option change.')
|
||||
}
|
||||
connect() // Call stable connect
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
addLog('debug', 'useMcp unmounting, disconnecting.')
|
||||
disconnect(true) // Call stable disconnect
|
||||
}
|
||||
}, [
|
||||
url,
|
||||
storageKeyPrefix,
|
||||
callbackUrl,
|
||||
clientName,
|
||||
clientUri,
|
||||
clientConfig.name,
|
||||
clientConfig.version,
|
||||
connect,
|
||||
disconnect, // Stable callbacks
|
||||
])
|
||||
|
||||
// Auto-Retry (depends on state, config, stable callbacks)
|
||||
useEffect(() => {
|
||||
let retryTimeoutId: number | null = null
|
||||
// Use state directly here, as this effect *should* run when state changes to 'failed'
|
||||
if (state === 'failed' && autoRetry && connectAttemptRef.current > 0) {
|
||||
const delay = typeof autoRetry === 'number' ? autoRetry : DEFAULT_RETRY_DELAY
|
||||
addLog('info', `Connection failed, auto-retrying in ${delay}ms...`)
|
||||
retryTimeoutId = setTimeout(() => {
|
||||
// Check mount status and state again before retrying
|
||||
if (isMountedRef.current && stateRef.current === 'failed') {
|
||||
retry() // Call stable retry
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
return () => {
|
||||
if (retryTimeoutId) clearTimeout(retryTimeoutId)
|
||||
}
|
||||
// Depends on state (to trigger), autoRetry config, and stable retry/addLog callbacks
|
||||
}, [state, autoRetry, retry, addLog])
|
||||
|
||||
// --- Return 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