Merge pull request #1 from geelen/gemini-https

Added StreamableHTTP support
This commit is contained in:
Glen Maddern 2025-05-07 07:04:47 +01:00 • committed by GitHub
commit 60fcce102b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 549 additions and 361 deletions

32
AGENT.md Normal file
View file

@ -0,0 +1,32 @@
# use-mcp Project Guidelines
## Build, Lint, and Test Commands
- `pnpm dev`: Run development build with watch mode
- `pnpm build`: Build the project
- `pnpm check`: Run prettier checks and TypeScript type checking
## Code Style Guidelines
### Imports
- Use explicit .js extensions in imports (ES modules style)
- Group imports: SDK imports first, followed by React/external deps, then local imports
### Formatting
- Single quotes for strings
- No semicolons at line ends
- 140 character line width
- Use 2 space indentation
### Types and Naming
- Strong typing with TypeScript
- Descriptive interface names with camelCase for variables/functions and PascalCase for types
- Comprehensive JSDoc comments for public API functions and types
### Error Handling
- Use assertions with descriptive messages
- Log errors with appropriate levels (debug, info, warn, error)
- Defensive error handling with specific error types when available
### React Patterns
- Use React hooks with useRef for mutable values
- Stable callbacks with useCallback and appropriate dependencies

187
README.md
View file

@ -1,90 +1,145 @@
# 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`).
A lightweight React hook for connecting to [Model Context Protocol (MCP)](https://github.com/modelcontextprotocol) servers. Simplifies authentication and tool calling for AI systems implementing the MCP standard.
## Installation
```bash
npm install use-mcp
# or
yarn add use-mcp
# or
pnpm add use-mcp
# or
yarn add use-mcp
```
## Util Exports
## Features
This main package (use-mcp) exports utilities primarily for handling OAuth in the browser:
- 🔄 Automatic connection management with reconnection and retries
- 🔐 OAuth authentication flow handling with popup and fallback support
- 📦 Simple React hook interface for MCP integration
- 🧰 TypeScript types for editor assistance and type checking
- 📝 Comprehensive logging for debugging
- 🌐 Works with both HTTP and SSE (Server-Sent Events) transports
* `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 [src/react/README.md](src/react/README.md) 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):
## Quick Start
```tsx
// pages/oauth/callback.js (or similar route file)
import { useEffect } from 'react'; // Or framework equivalent
import { onMcpAuthorization } from 'use-mcp';
import { useMcp } from 'use-mcp/react'
export default function OAuthCallbackPage() {
useEffect(() => {
// Extract query parameters from window.location.search
const queryParams = Object.fromEntries(
new URLSearchParams(window.location.search).entries()
);
function MyAIComponent() {
const {
state, // Connection state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed'
tools, // Available tools from MCP server
error, // Error message if connection failed
callTool, // Function to call tools on the MCP server
retry, // Retry connection manually
authenticate, // Manually trigger authentication
clearStorage, // Clear stored tokens and credentials
} = useMcp({
url: 'https://your-mcp-server.com',
clientName: 'My App',
autoReconnect: true,
})
// 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>`;
});
// Handle different states
if (state === 'failed') {
return (
<div>
<p>Connection failed: {error}</p>
<button onClick={retry}>Retry</button>
<button onClick={authenticate}>Authenticate Manually</button>
</div>
)
}
}, []); // Run only once on component mount
if (state !== 'ready') {
return <div>Connecting to AI service...</div>
}
// Render a loading state while the exchange happens
return <div>Processing authentication...</div>;
// Use available tools
const handleSearch = async () => {
try {
const result = await callTool('search', { query: 'example search' })
console.log('Search results:', result)
} catch (err) {
console.error('Tool call failed:', err)
}
}
return (
<div>
<h2>Available Tools: {tools.length}</h2>
<ul>
{tools.map(tool => (
<li key={tool.name}>{tool.name}</li>
))}
</ul>
<button onClick={handleSearch}>Search</button>
</div>
)
}
```
Future Plans
## Setting Up OAuth Callback
* Support for other frameworks (Vue, Svelte, etc.).
* Improved error handling and recovery.
* Support for different OAuth flows if necessary.
To handle the OAuth authentication flow, you need to set up a callback endpoint in your app:
Contributions and feedback are welcome!
```tsx
// pages/oauth/callback.tsx or equivalent
import { useEffect } from 'react'
import { onMcpAuthorization } from 'use-mcp'
export default function OAuthCallbackPage() {
useEffect(() => {
onMcpAuthorization()
}, [])
return (
<div>
<h1>Authenticating...</h1>
<p>This window should close automatically.</p>
</div>
)
}
```
## API Reference
### `useMcp` Hook
```tsx
function useMcp(options: UseMcpOptions): UseMcpResult
```
#### Options
| Option | Type | Description |
|--------|------|-------------|
| `url` | `string` | **Required**. URL of your MCP server |
| `clientName` | `string` | Name of your client for OAuth registration |
| `clientUri` | `string` | URI of your client for OAuth registration |
| `callbackUrl` | `string` | Custom callback URL for OAuth redirect (defaults to `/oauth/callback` on the current origin) |
| `storageKeyPrefix` | `string` | Storage key prefix for OAuth data in localStorage (defaults to "mcp:auth") |
| `clientConfig` | `object` | Custom configuration for the MCP client identity |
| `debug` | `boolean` | Whether to enable verbose debug logging |
| `autoRetry` | `boolean \| number` | Auto retry connection if initial connection fails, with delay in ms |
| `autoReconnect` | `boolean \| number` | Auto reconnect if an established connection is lost, with delay in ms (default: 3000) |
#### Return Value
| Property | Type | Description |
|----------|------|-------------|
| `state` | `string` | Current connection state: 'discovering', 'authenticating', 'connecting', 'loading', 'ready', 'failed' |
| `tools` | `Tool[]` | Available tools from the MCP server |
| `error` | `string \| undefined` | Error message if connection failed |
| `authUrl` | `string \| undefined` | Manual authentication URL if popup is blocked |
| `log` | `{ level: 'debug' \| 'info' \| 'warn' \| 'error'; message: string; timestamp: number }[]` | Array of log messages |
| `callTool` | `(name: string, args?: Record<string, unknown>) => Promise<any>` | Function to call a tool on the MCP server |
| `retry` | `() => void` | Manually attempt to reconnect |
| `disconnect` | `() => void` | Disconnect from the MCP server |
| `authenticate` | `() => void` | Manually trigger authentication |
| `clearStorage` | `() => void` | Clear all stored authentication data |
## License
MIT

View file

@ -1,16 +1,22 @@
// useMcp.ts
import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js';
import { useCallback, useEffect, useRef, useState } from 'react';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { 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 { 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;
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 {
@ -20,120 +26,125 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
callbackUrl = typeof window !== 'undefined' ? new URL('/oauth/callback', window.location.origin).toString() : '/oauth/callback',
storageKeyPrefix = 'mcp:auth',
clientConfig = {},
debug = false, // Consider using this in addLog
debug = false,
autoRetry = false,
autoReconnect = DEFAULT_RECONNECT_DELAY,
} = options;
} = options
const [state, setState] = useState<UseMcpResult['state']>('discovering');
const [tools, setTools] = useState<Tool[]>([]);
const [error, setError] = useState<string | undefined>(undefined);
const [log, setLog] = useState<UseMcpResult['log']>([]);
const [authUrl, setAuthUrl] = useState<string | undefined>(undefined);
const [state, setState] = useState<UseMcpResult['state']>('discovering')
const [tools, setTools] = useState<Tool[]>([])
const [error, setError] = useState<string | undefined>(undefined)
const [log, setLog] = useState<UseMcpResult['log']>([])
const [authUrl, setAuthUrl] = useState<string | undefined>(undefined)
const clientRef = useRef<Client | null>(null);
const transportRef = useRef<SSEClientTransport | null>(null);
const authProviderRef = useRef<BrowserOAuthClientProvider | null>(null);
const connectingRef = useRef<boolean>(false);
const isMountedRef = useRef<boolean>(true);
const connectAttemptRef = useRef<number>(0);
const authTimeoutRef = useRef<number | null>(null);
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 ---
// Ref to hold the latest state value for use in callbacks without causing dependency changes
const stateRef = useRef(state);
// Ref to hold autoReconnect option
const autoReconnectRef = useRef(autoReconnect);
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]);
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}`);
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() }]);
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;
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
const transport = transportRef.current
clientRef.current = null // Ensure client is cleared
transportRef.current = null // Ensure transport is cleared
// Only reset state if mounted and not a quiet disconnect
if (isMountedRef.current && !quiet) {
setState('discovering');
setTools([]);
setError(undefined);
setAuthUrl(undefined);
setState('discovering')
setTools([])
setError(undefined)
setAuthUrl(undefined)
}
if (transport) {
try {
await transport.close();
if (!quiet) addLog('debug', 'Transport closed');
await transport.close()
if (!quiet) addLog('debug', 'Transport closed')
} catch (err) {
if (!quiet) addLog('warn', 'Error closing transport:', err);
if (!quiet) addLog('warn', 'Error closing transport:', err)
}
}
},
[addLog], // 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);
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
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;
addLog('debug', 'Connection attempt already in progress.')
return
}
// Check mounted status *before* starting async logic
if (!isMountedRef.current) {
addLog('debug', 'Connect called after unmount, aborting.');
return;
addLog('debug', 'Connect called after unmount, aborting.')
return
}
connectingRef.current = true;
connectAttemptRef.current += 1;
setError(undefined);
setAuthUrl(undefined);
setState('discovering'); // Set state *before* async calls
addLog('info', `Connecting attempt #${connectAttemptRef.current} to ${url}...`);
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, {
@ -149,332 +160,422 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
addLog('debug', 'MCP Client initialized in connect.');
}
// Recreate transport
setState('connecting'); // Update state
if (transportRef.current) {
await transportRef.current.close().catch(e => addLog('warn', 'Error closing previous transport:', e));
transportRef.current = null;
}
try {
// Assert provider/client are definitely created now
assert(authProviderRef.current, "Auth Provider must be initialized");
assert(clientRef.current, "Client must be initialized");
transportRef.current = new SSEClientTransport(new URL(url), {
authProvider: authProviderRef.current,
});
addLog('debug', 'SSEClientTransport created.');
} catch(err) {
failConnection(`Failed to create transport: ${err instanceof Error ? err.message : String(err)}`, err instanceof Error ? err : undefined);
// connectingRef is set to false inside failConnection
return; // Stop execution
}
// --- Transport Handlers ---
// These handlers are defined inline within connect's scope.
// They will implicitly capture the *current* stable callbacks like addLog, failConnection.
// They use refs (stateRef, autoReconnectRef) to access current state/options when needed.
transportRef.current.onmessage = (message: JSONRPCMessage) => {
addLog('debug', `[Transport] Received: ${JSON.stringify(message)}`);
// Forward to the *current* client instance
// @ts-ignore
clientRef.current?.handleMessage(message);
};
transportRef.current.onerror = (err: Error) => {
failConnection(`Transport error: ${err.message}`, err);
};
transportRef.current.onclose = () => {
// Use refs to check state and options without needing them as dependencies
if (!isMountedRef.current || connectingRef.current) return;
addLog('info', 'Transport connection closed.');
const currentState = stateRef.current; // Use ref
const currentAutoReconnect = autoReconnectRef.current; // Use ref
if (currentState === 'ready' && currentAutoReconnect) {
const delay = typeof currentAutoReconnect === 'number' ? currentAutoReconnect : DEFAULT_RECONNECT_DELAY;
addLog('info', `Attempting to reconnect in ${delay}ms...`);
setState('connecting'); // Update state for UI feedback
setTimeout(() => {
// Check mounted status again before actually connecting
if (isMountedRef.current) {
connect(); // Call stable connect again
}
}, delay);
} else if (currentState !== 'failed' && currentState !== 'authenticating') {
failConnection('Connection closed unexpectedly.');
// --- 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')
}
};
// --- Connect Client & Handle Auth ---
try {
addLog('info', 'Connecting client...');
// Client/transport refs are already asserted above
await clientRef.current!.connect(transportRef.current);
let transportInstance: Transport // Use base Transport type
addLog('info', 'Client connected. Loading tools...');
setState('loading'); // Update state
// 1. Create Transport Instance & Close Previous
try {
assert(authProviderRef.current, 'Auth Provider must be initialized')
assert(clientRef.current, 'Client must be initialized')
const toolsResponse = await clientRef.current!.request({ method: 'tools/list' }, ListToolsResultSchema);
// Check mounted status *before* final state update
if (isMountedRef.current) {
setTools(toolsResponse.tools);
addLog('info', `Loaded ${toolsResponse.tools.length} tools.`);
setState('ready'); // Final success state
connectingRef.current = false; // Connection attempt finished successfully
connectAttemptRef.current = 0; // Reset attempt counter
}
} catch (connectErr) {
addLog('debug', 'Client connect error:', connectErr);
const errorInstance = connectErr instanceof Error ? connectErr : new Error(String(connectErr));
if (errorInstance instanceof UnauthorizedError || errorInstance.message.includes('Unauthorized') || errorInstance.message.includes('401')) {
addLog('info', 'Authentication required. Initiating SDK auth flow...');
setState('authenticating'); // Update state
// Start auth timeout
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current);
authTimeoutRef.current = setTimeout(() => {
// Check ref state before failing
if (isMountedRef.current && stateRef.current === 'authenticating') {
failConnection('Authentication timed out. Please try again or use the manual link if available.');
}
}, AUTH_TIMEOUT);
try {
assert(authProviderRef.current, "Auth Provider not available for auth flow");
const authResult = await auth(authProviderRef.current, { serverUrl: url });
// Check mounted status before proceeding
if (!isMountedRef.current) return;
if (authResult === 'AUTHORIZED') {
addLog('info', 'Authentication successful via existing token or refresh. Reconnecting...');
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current);
connectingRef.current = false; // Allow reconnect
connect(); // Call stable connect to re-establish
} else if (authResult === 'REDIRECT') {
addLog('info', 'Redirecting for authentication. Waiting for callback...');
// State is 'authenticating', timeout running. connectingRef remains true.
}
} catch (sdkAuthError) {
if (!isMountedRef.current) return; // Check mount before failConnection
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current);
failConnection(`Failed to initiate authentication: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`, sdkAuthError instanceof Error ? sdkAuthError : undefined);
// connectingRef set to false inside failConnection
// 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
}
} else {
// Handle other connection errors
failConnection(`Failed to connect: ${errorInstance.message}`, errorInstance);
// connectingRef set to false inside failConnection
const commonOptions: SSEClientTransportOptions = { authProvider: authProviderRef.current }
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
}
// Only set connectingRef false here if NOT waiting for redirect
// Note: failConnection already sets it to false. If redirect happens, it remains true.
// If AUTHORIZED happens, we immediately call connect again, which sets it true.
// This seems correct.
// 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)
// Use stable failConnection
failConnection(
`Failed to initiate authentication: ${sdkAuthError instanceof Error ? sdkAuthError.message : String(sdkAuthError)}`,
sdkAuthError instanceof Error ? sdkAuthError : undefined,
)
return 'failed' // Auth initiation failed
}
}
// Handle other connection errors
// Use stable failConnection
failConnection(`Failed to connect via ${transportType.toUpperCase()}: ${errorMessage}`, errorInstance)
return 'fallback' // If our logic above is wrong, we'd prevent HTTPs servers from ever working. So always fall back as a last resort.
}
} // 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 failed/redirected
if (httpResult === 'fallback' && isMountedRef.current && stateRef.current !== 'failed' && 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
}
// --- 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,
// Configuration dependencies that require full reconnect if changed
url, storageKeyPrefix, clientName, clientUri, callbackUrl,
// clientConfig changes might require reconnect? Add if necessary:
// clientConfig.name, clientConfig.version
// Note: connect does NOT depend on state or autoReconnect anymore
]);
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}".`);
throw new Error(`MCP client is not ready (current state: ${state}). Cannot call tool "${name}".`)
}
addLog('info', `Calling tool: ${name}`, args);
addLog('info', `Calling tool: ${name}`, args)
try {
const result = await clientRef.current.request(
{ method: 'tools/call', params: { name, arguments: args } },
CallToolResultSchema,
);
addLog('info', `Tool "${name}" call successful:`, result);
return result;
const result = await clientRef.current.request({ method: 'tools/call', params: { name, arguments: args } }, CallToolResultSchema)
addLog('info', `Tool "${name}" call successful:`, result)
return result
} catch (err) {
addLog('error', `Error calling tool "${name}": ${err instanceof Error ? err.message : String(err)}`, err);
const errorInstance = err instanceof Error ? err : new Error(String(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);
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 });
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 (!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);
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
connectingRef.current = false
connect() // Reconnect session
} else if (authResult === 'REDIRECT') {
addLog('info', 'Redirecting for re-authentication for tool call.');
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);
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 (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?
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...');
addLog('info', 'Retry requested...')
// connect() will handle resetting state and error internally
connect();
connect()
} else {
addLog('warn', `Retry called but state is not 'failed' (state: ${stateRef.current}). Ignoring.`);
addLog('warn', `Retry called but state is not 'failed' (state: ${stateRef.current}). Ignoring.`)
}
}, [addLog, connect]); // Depends only on stable callbacks
}, [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
addLog('info', 'Manual authentication requested...')
const currentState = stateRef.current // Use ref
if (currentState === 'failed') {
addLog('info', 'Attempting to reconnect and authenticate via retry...');
retry();
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);
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.`);
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
}, [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
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();
disconnect()
} else {
addLog('warn', 'Auth provider not initialized, cannot clear storage.');
addLog('warn', 'Auth provider not initialized, cannot clear storage.')
}
}, [url, addLog, disconnect]); // Depends on url and stable callbacks
}, [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.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);
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
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
failConnection(`Authentication failed in callback: ${event.data.error || 'Unknown reason.'}`) // Call stable failConnection
}
}
};
window.addEventListener('message', messageHandler);
addLog('debug', 'Auth callback message listener added.');
}
window.addEventListener('message', messageHandler)
addLog('debug', 'Auth callback message listener added.')
return () => {
window.removeEventListener('message', messageHandler);
addLog('debug', 'Auth callback message listener removed.');
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current);
};
window.removeEventListener('message', messageHandler)
addLog('debug', 'Auth callback message listener removed.')
if (authTimeoutRef.current) clearTimeout(authTimeoutRef.current)
}
// Dependencies are stable callbacks
}, [addLog, failConnection, connect]);
}, [addLog, failConnection, connect])
// Effect for initial connection (Stable dependencies)
// Initial Connection (depends on config and stable callbacks)
useEffect(() => {
isMountedRef.current = true;
addLog('debug', 'useMcp mounted, initiating connection.');
connectAttemptRef.current = 0;
// Initialize provider here if it doesn't exist, ensuring it uses latest options
/* ... 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.');
addLog('debug', 'BrowserOAuthClientProvider initialized/updated on mount/option change.')
}
connect(); // Call stable connect
connect() // Call stable connect
return () => {
isMountedRef.current = false;
addLog('debug', 'useMcp unmounting, disconnecting.');
disconnect(true); // Call stable disconnect
};
// Ensure all config options that influence provider/connect are dependencies
isMountedRef.current = false
addLog('debug', 'useMcp unmounting, disconnecting.')
disconnect(true) // Call stable disconnect
}
}, [
url, storageKeyPrefix, callbackUrl, clientName, clientUri, // Core config
clientConfig.name, clientConfig.version, // Include client config if it affects Client init
connect, disconnect // Stable callbacks are OK as deps
]);
url,
storageKeyPrefix,
callbackUrl,
clientName,
clientUri,
clientConfig.name,
clientConfig.version,
connect,
disconnect, // Stable callbacks
])
// Effect for auto-retry logic (Stable dependencies)
// Auto-Retry (depends on state, config, stable callbacks)
useEffect(() => {
let retryTimeoutId: number | null = null;
let retryTimeoutId: number | null = null
// Use state directly here, as this effect *should* run when state changes to 'failed'
if (state === 'failed' && autoRetry && connectAttemptRef.current > 0) {
const delay = typeof autoRetry === 'number' ? autoRetry : DEFAULT_RETRY_DELAY;
addLog('info', `Connection failed, auto-retrying in ${delay}ms...`);
const delay = typeof autoRetry === 'number' ? autoRetry : DEFAULT_RETRY_DELAY
addLog('info', `Connection failed, auto-retrying in ${delay}ms...`)
retryTimeoutId = setTimeout(() => {
// Check mount status and state again before retrying
if (isMountedRef.current && stateRef.current === 'failed') {
retry(); // Call stable retry
retry() // Call stable retry
}
}, delay);
}, delay)
}
return () => {
if (retryTimeoutId) clearTimeout(retryTimeoutId);
};
if (retryTimeoutId) clearTimeout(retryTimeoutId)
}
// Depends on state (to trigger), autoRetry config, and stable retry/addLog callbacks
}, [state, autoRetry, retry, addLog]);
}, [state, autoRetry, retry, addLog])
// --- Return Public API ---
return {
state,
tools,
@ -486,5 +587,5 @@ export function useMcp(options: UseMcpOptions): UseMcpResult {
disconnect,
authenticate,
clearStorage,
};
}
}
}