From eca1e453637bc0f0741abeeb93b0c97a3787bb78 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Sat, 22 Mar 2025 21:24:39 +1100 Subject: [PATCH 1/5] Gearing up for Claude to write this react hook --- package.json | 10 +++- pnpm-lock.yaml | 9 +++ src/{ => cli}/client.ts | 2 +- src/{ => cli}/proxy.ts | 5 +- src/{ => cli}/shared.ts | 130 +--------------------------------------- src/lib/types.ts | 31 ++++++++++ src/lib/utils.ts | 100 +++++++++++++++++++++++++++++++ src/react/index.ts | 55 +++++++++++++++++ tsup.config.ts | 2 +- 9 files changed, 210 insertions(+), 134 deletions(-) rename src/{ => cli}/client.ts (98%) rename src/{ => cli}/proxy.ts (96%) rename src/{ => cli}/shared.ts (71%) create mode 100644 src/lib/types.ts create mode 100644 src/lib/utils.ts create mode 100644 src/react/index.ts diff --git a/package.json b/package.json index bd33bef..4ea39d4 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,19 @@ "name": "mcp-remote", "version": "0.0.4", "type": "module", - "bin": "dist/proxy.js", + "bin": "dist/cli/proxy.js", "files": [ "dist", "README.md", "LICENSE" ], + "exports": { + "./react": { + "types": "./dist/react/index.d.ts", + "require": "./dist/react/index.js", + "import": "./dist/react/index.js" + } + }, "scripts": { "build": "tsup" }, @@ -20,6 +27,7 @@ "@types/express": "^5.0.0", "@types/node": "^22.13.10", "prettier": "^3.5.3", + "react": "^19.0.0", "tsup": "^8.4.0", "tsx": "^4.19.3", "typescript": "^5.8.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fed362..6821d5f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: prettier: specifier: ^3.5.3 version: 3.5.3 + react: + specifier: ^19.0.0 + version: 19.0.0 tsup: specifier: ^8.4.0 version: 8.4.0(tsx@4.19.3)(typescript@5.8.2) @@ -903,6 +906,10 @@ packages: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} + react@19.0.0: + resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} + engines: {node: '>=0.10.0'} + readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} @@ -1904,6 +1911,8 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 + react@19.0.0: {} + readdirp@4.1.2: {} resolve-from@5.0.0: {} diff --git a/src/client.ts b/src/cli/client.ts similarity index 98% rename from src/client.ts rename to src/cli/client.ts index 245f6e9..f79e917 100644 --- a/src/client.ts +++ b/src/cli/client.ts @@ -14,7 +14,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared' +import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js' /** * Main function to run the client diff --git a/src/proxy.ts b/src/cli/proxy.ts similarity index 96% rename from src/proxy.ts rename to src/cli/proxy.ts index a6e2388..763a9b1 100644 --- a/src/proxy.ts +++ b/src/cli/proxy.ts @@ -14,11 +14,10 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { NodeOAuthClientProvider, setupOAuthCallbackServer, - connectToRemoteServer, - mcpProxy, parseCommandLineArgs, setupSignalHandlers, -} from './shared' +} from './shared.js' +import {connectToRemoteServer, mcpProxy} from "../lib/utils.js"; /** * Main function to run the proxy diff --git a/src/shared.ts b/src/cli/shared.ts similarity index 71% rename from src/shared.ts rename to src/cli/shared.ts index 933ce4a..28bc1b1 100644 --- a/src/shared.ts +++ b/src/cli/shared.ts @@ -9,11 +9,8 @@ import fs from 'fs/promises' import path from 'path' import os from 'os' import crypto from 'crypto' -import { EventEmitter } from 'events' import net from 'net' -import { OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import {OAuthClientProvider} from '@modelcontextprotocol/sdk/client/auth.js' import { OAuthClientInformation, OAuthClientInformationFull, @@ -21,24 +18,7 @@ import { OAuthTokens, OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' - -/** - * Options for creating an OAuth client provider - */ -export interface OAuthProviderOptions { - /** Server URL to connect to */ - serverUrl: string - /** Port for the OAuth callback server */ - callbackPort: number - /** Path for the OAuth callback endpoint */ - callbackPath?: string - /** Directory to store OAuth credentials */ - configDir?: string - /** Client name to use for OAuth registration */ - clientName?: string - /** Client URI to use for OAuth registration */ - clientUri?: string -} +import {OAuthCallbackServerOptions, OAuthProviderOptions} from "../lib/types.js"; /** * Implements the OAuthClientProvider interface for Node.js environments. @@ -225,18 +205,6 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { } } -/** - * OAuth callback server setup options - */ -export interface OAuthCallbackServerOptions { - /** Port for the callback server */ - port: number - /** Path for the callback endpoint */ - path: string - /** Event emitter to signal when auth code is received */ - events: EventEmitter -} - /** * Sets up an Express server to handle OAuth callbacks * @param options The server options @@ -284,100 +252,6 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { return { server, authCode, waitForAuthCode } } -/** - * Creates and connects to a remote SSE server with OAuth authentication - * @param serverUrl The URL of the remote server - * @param authProvider The OAuth client provider - * @param waitForAuthCode Function to wait for the auth code - * @returns The connected SSE client transport - */ -export async function connectToRemoteServer( - serverUrl: string, - authProvider: OAuthClientProvider, - waitForAuthCode: () => Promise, -): Promise { - console.error('Connecting to remote server:', serverUrl) - const url = new URL(serverUrl) - const transport = new SSEClientTransport(url, { authProvider }) - - try { - await transport.start() - console.error('Connected to remote server') - return transport - } catch (error) { - if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { - console.error('Authentication required. Waiting for authorization...') - - // Wait for the authorization code from the callback - const code = await waitForAuthCode() - - try { - console.error('Completing authorization...') - await transport.finishAuth(code) - - // Create a new transport after auth - const newTransport = new SSEClientTransport(url, { authProvider }) - await newTransport.start() - console.error('Connected to remote server after authentication') - return newTransport - } catch (authError) { - console.error('Authorization error:', authError) - throw authError - } - } else { - console.error('Connection error:', error) - throw error - } - } -} - -/** - * Creates a bidirectional proxy between two transports - * @param params The transport connections to proxy between - */ -export function mcpProxy({ transportToClient, transportToServer }: { transportToClient: Transport; transportToServer: Transport }) { - let transportToClientClosed = false - let transportToServerClosed = false - - transportToClient.onmessage = (message) => { - console.error('[Local→Remote]', message.method || message.id) - transportToServer.send(message).catch(onServerError) - } - - transportToServer.onmessage = (message) => { - console.error('[Remote→Local]', message.method || message.id) - transportToClient.send(message).catch(onClientError) - } - - transportToClient.onclose = () => { - if (transportToServerClosed) { - return - } - - transportToClientClosed = true - transportToServer.close().catch(onServerError) - } - - transportToServer.onclose = () => { - if (transportToClientClosed) { - return - } - transportToServerClosed = true - transportToClient.close().catch(onClientError) - } - - transportToClient.onerror = onClientError - transportToServer.onerror = onServerError - - function onClientError(error: Error) { - console.error('Error from local client:', error) - } - - function onServerError(error: Error) { - console.error('Error from remote server:', error) - } -} - /** * Finds an available port on the local machine * @param preferredPort Optional preferred port to try first diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..2201eb5 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,31 @@ +import {EventEmitter} from "events"; + +/** + * Options for creating an OAuth client provider + */ +export interface OAuthProviderOptions { + /** Server URL to connect to */ + serverUrl: string + /** Port for the OAuth callback server */ + callbackPort: number + /** Path for the OAuth callback endpoint */ + callbackPath?: string + /** Directory to store OAuth credentials */ + configDir?: string + /** Client name to use for OAuth registration */ + clientName?: string + /** Client URI to use for OAuth registration */ + clientUri?: string +} + +/** + * OAuth callback server setup options + */ +export interface OAuthCallbackServerOptions { + /** Port for the callback server */ + port: number + /** Path for the callback endpoint */ + path: string + /** Event emitter to signal when auth code is received */ + events: EventEmitter +} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..2a51a47 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,100 @@ +import { OAuthClientProvider, UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; + +/** + * Creates a bidirectional proxy between two transports + * @param params The transport connections to proxy between + */ +export function mcpProxy({transportToClient, transportToServer}: { + transportToClient: Transport; + transportToServer: Transport +}) { + let transportToClientClosed = false + let transportToServerClosed = false + + transportToClient.onmessage = (message) => { + console.error('[Local→Remote]', message.method || message.id) + transportToServer.send(message).catch(onServerError) + } + + transportToServer.onmessage = (message) => { + console.error('[Remote→Local]', message.method || message.id) + transportToClient.send(message).catch(onClientError) + } + + transportToClient.onclose = () => { + if (transportToServerClosed) { + return + } + + transportToClientClosed = true + transportToServer.close().catch(onServerError) + } + + transportToServer.onclose = () => { + if (transportToClientClosed) { + return + } + transportToServerClosed = true + transportToClient.close().catch(onClientError) + } + + transportToClient.onerror = onClientError + transportToServer.onerror = onServerError + + function onClientError(error: Error) { + console.error('Error from local client:', error) + } + + function onServerError(error: Error) { + console.error('Error from remote server:', error) + } +} + +/** + * Creates and connects to a remote SSE server with OAuth authentication + * @param serverUrl The URL of the remote server + * @param authProvider The OAuth client provider + * @param waitForAuthCode Function to wait for the auth code + * @returns The connected SSE client transport + */ +export async function connectToRemoteServer( + serverUrl: string, + authProvider: OAuthClientProvider, + waitForAuthCode: () => Promise, +): Promise { + console.error('Connecting to remote server:', serverUrl) + const url = new URL(serverUrl) + const transport = new SSEClientTransport(url, {authProvider}) + + try { + await transport.start() + console.error('Connected to remote server') + return transport + } catch (error) { + if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { + console.error('Authentication required. Waiting for authorization...') + + // Wait for the authorization code from the callback + const code = await waitForAuthCode() + + try { + console.error('Completing authorization...') + await transport.finishAuth(code) + + // Create a new transport after auth + const newTransport = new SSEClientTransport(url, {authProvider}) + await newTransport.start() + console.error('Connected to remote server after authentication') + return newTransport + } catch (authError) { + console.error('Authorization error:', authError) + throw authError + } + } else { + console.error('Connection error:', error) + throw error + } + } +} \ No newline at end of file diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 0000000..39c3139 --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,55 @@ +import { Tool } from "@modelcontextprotocol/sdk/types.js"; +import { useCallback, useState } from "react"; + +export type UseMcpOptions = { + /** The /sse URL of your remote MCP server */ + url: string, + + // more options here as I think of them +} + +export type UseMcpResult = { + tools: Tool[], + /** + * The current state of the MCP connection. This will be one of: + * - 'discovering': Finding out whether there is in fact a server at that URL, and what its capabilities are + * - 'authenticating': The server has indicated we must authenticate, so we can't proceed until that's complete + * - 'connecting': The connection to the MCP server is being established. This happens before we know whether we need to authenticate or not, and then again once we have credentials + * - 'loading': We're connected to the MCP server, and now we're loading its resources/prompts/tools + * - 'ready': The MCP server is connected and ready to be used + * - 'failed': The connection to the MCP server failed + * */ + state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed', + /** If the state is 'failed', this will be the error message */ + error?: string, + /** All internal log messages */ + log: {level: 'debug' | 'info' | 'warn' | 'error', message: string}[], + + // more as i think of them +} + +/** + * useMcp is a React hook that connects to a remote MCP server, negotiates auth + * (including opening a popup window or new tab to complete the OAuth flow), + * and enables passing a list of tools (once loaded) to ai-sdk (using `useChat`). + * + * The authorization flow + */ +export function useMcp( + options: UseMcpOptions +):UseMcpResult { + // TODO: implement hook +} + +/** + * onMcpAuthorization is invoked when the oauth flow completes. This is usually mounted + * on /oauth/callback, and passed the entire URL query parameters. This first uses the state + * parameter to look up in LocalStorage the context for the current auth flow, and then + * completes the flow by exchanging the authorization code for an access token. + * + * Once it's updated LocalStorage with the auth token, it will post a message back to the original + * window to inform any running `useMcp` hooks that the auth flow is complete. + */ +export async function onMcpAuthorization(query: Record) { + // TODO: implement +} \ No newline at end of file diff --git a/tsup.config.ts b/tsup.config.ts index c1849c0..68da9d3 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/client.ts', 'src/proxy.ts'], + entry: ['src/cli/client.ts', 'src/cli/proxy.ts', 'src/react/index.ts'], format: ['esm'], dts: true, clean: true, From e6a22d452e81953886d71282163565b5396ab383 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Sat, 22 Mar 2025 22:29:16 +1100 Subject: [PATCH 2/5] got something building and activating in a react app, at least! --- package.json | 1 + pnpm-lock.yaml | 15 + src/react/index.ts | 662 ++++++++++++++++++++++++++++++++++++++++++++- tsconfig.json | 5 +- tsup.config.ts | 2 +- 5 files changed, 670 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 4ea39d4..479bedd 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "devDependencies": { "@types/express": "^5.0.0", "@types/node": "^22.13.10", + "@types/react": "^19.0.12", "prettier": "^3.5.3", "react": "^19.0.0", "tsup": "^8.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6821d5f..ca214d2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@types/node': specifier: ^22.13.10 version: 22.13.10 + '@types/react': + specifier: ^19.0.12 + version: 19.0.12 prettier: specifier: ^3.5.3 version: 3.5.3 @@ -347,6 +350,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/react@19.0.12': + resolution: {integrity: sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==} + '@types/send@0.17.4': resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} @@ -473,6 +479,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + debug@2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -1340,6 +1349,10 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/react@19.0.12': + dependencies: + csstype: 3.1.3 + '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 @@ -1476,6 +1489,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.1.3: {} + debug@2.6.9: dependencies: ms: 2.0.0 diff --git a/src/react/index.ts b/src/react/index.ts index 39c3139..d3408b2 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,11 +1,33 @@ -import { Tool } from "@modelcontextprotocol/sdk/types.js"; -import { useCallback, useState } from "react"; +import { Tool, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import { useCallback, useEffect, useState, useRef } from "react"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import { discoverOAuthMetadata, startAuthorization, exchangeAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"; +import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; export type UseMcpOptions = { /** The /sse URL of your remote MCP server */ url: string, - - // more options here as I think of them + /** OAuth client name for registration */ + clientName?: string, + /** OAuth client URI for registration */ + clientUri?: string, + /** Custom callback URL for OAuth redirect (defaults to /oauth/callback on the current origin) */ + callbackUrl?: string, + /** Storage key prefix for OAuth data (defaults to "mcp_auth") */ + storageKeyPrefix?: string, + /** Custom configuration for the MCP client */ + clientConfig?: { + name?: string, + version?: string, + }, + /** Whether to enable debug logging */ + debug?: boolean, + /** Auto retry connection if it fails, with delay in ms (default: false) */ + autoRetry?: boolean | number, + /** Auto reconnect if connection is lost, with delay in ms (default: 3000) */ + autoReconnect?: boolean | number, } export type UseMcpResult = { @@ -24,21 +46,515 @@ export type UseMcpResult = { error?: string, /** All internal log messages */ log: {level: 'debug' | 'info' | 'warn' | 'error', message: string}[], + /** Call a tool on the MCP server */ + callTool: (name: string, args?: Record) => Promise, + /** Manually retry connection if it's in a failed state */ + retry: () => void, + /** Manually disconnect from the MCP server */ + disconnect: () => void, +} - // more as i think of them +/** + * Browser-compatible OAuth client provider for MCP + */ +class BrowserOAuthClientProvider { + private storageKeyPrefix: string; + private serverUrlHash: string; + private clientName: string; + private clientUri: string; + private callbackUrl: string; + + constructor( + readonly serverUrl: string, + options: { + storageKeyPrefix?: string; + clientName?: string; + clientUri?: string; + callbackUrl?: string; + } = {} + ) { + this.storageKeyPrefix = options.storageKeyPrefix || "mcp_auth"; + this.serverUrlHash = this.hashString(serverUrl); + this.clientName = options.clientName || "MCP Browser Client"; + this.clientUri = options.clientUri || window.location.origin; + this.callbackUrl = options.callbackUrl || new URL("/oauth/callback", window.location.origin).toString(); + } + + get redirectUrl(): string { + return this.callbackUrl; + } + + get clientMetadata() { + return { + redirect_uris: [this.redirectUrl], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + client_name: this.clientName, + client_uri: this.clientUri, + }; + } + + private hashString(str: string): string { + // Simple hash function for browser environments + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32bit integer + } + return Math.abs(hash).toString(16); + } + + private getKey(key: string): string { + return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}`; + } + + async clientInformation(): Promise { + const key = this.getKey("client_info"); + const data = localStorage.getItem(key); + if (!data) return undefined; + + try { + return JSON.parse(data) as OAuthClientInformation; + } catch (e) { + return undefined; + } + } + + async saveClientInformation(clientInformation: OAuthClientInformation): Promise { + const key = this.getKey("client_info"); + localStorage.setItem(key, JSON.stringify(clientInformation)); + } + + async tokens(): Promise { + const key = this.getKey("tokens"); + const data = localStorage.getItem(key); + if (!data) return undefined; + + try { + return JSON.parse(data) as OAuthTokens; + } catch (e) { + return undefined; + } + } + + async saveTokens(tokens: OAuthTokens): Promise { + const key = this.getKey("tokens"); + localStorage.setItem(key, JSON.stringify(tokens)); + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + // Store the auth state for the popup flow + const stateKey = this.getKey("auth_state"); + const state = Math.random().toString(36).substring(2); + localStorage.setItem(stateKey, state); + authorizationUrl.searchParams.set("state", state); + + // Open the authorization URL in a popup window + const popup = window.open(authorizationUrl.toString(), "mcp_auth", "width=600,height=700"); + + if (!popup || popup.closed) { + console.warn("Popup blocked. Redirecting in the same window..."); + window.location.href = authorizationUrl.toString(); + } + } + + async saveCodeVerifier(codeVerifier: string): Promise { + const key = this.getKey("code_verifier"); + localStorage.setItem(key, codeVerifier); + } + + async codeVerifier(): Promise { + const key = this.getKey("code_verifier"); + const verifier = localStorage.getItem(key); + if (!verifier) { + throw new Error("No code verifier found in storage"); + } + return verifier; + } } /** * useMcp is a React hook that connects to a remote MCP server, negotiates auth * (including opening a popup window or new tab to complete the OAuth flow), * and enables passing a list of tools (once loaded) to ai-sdk (using `useChat`). - * - * The authorization flow */ -export function useMcp( - options: UseMcpOptions -):UseMcpResult { - // TODO: implement hook +export function useMcp(options: UseMcpOptions): UseMcpResult { + const [state, setState] = useState('discovering'); + const [tools, setTools] = useState([]); + const [error, setError] = useState(undefined); + const [log, setLog] = useState([]); + + const clientRef = useRef(null); + const transportRef = useRef(null); + const authProviderRef = useRef(null); + const metadataRef = useRef(undefined); + const connectingRef = useRef(false); + const isInitialMount = useRef(true); + + // Set up default options + const { + url, + clientName = "MCP React Client", + clientUri = window.location.origin, + callbackUrl = new URL("/oauth/callback", window.location.origin).toString(), + storageKeyPrefix = "mcp_auth", + clientConfig = { + name: "mcp-react-client", + version: "0.1.0", + }, + debug = false, + autoRetry = false, + autoReconnect = 3000, + } = options; + + // Add to log + const addLog = useCallback((level: 'debug' | 'info' | 'warn' | 'error', message: string) => { + if (level === 'debug' && !debug) return; + setLog(prevLog => [...prevLog, { level, message }]); + }, [debug]); + + // Call a tool on the MCP server + const callTool = useCallback(async (name: string, args?: Record) => { + if (!clientRef.current || state !== 'ready') { + throw new Error("MCP client not ready"); + } + + try { + const result = await clientRef.current.request( + { + method: "tools/call", + params: { name, arguments: args }, + } + ); + return result; + } catch (err) { + addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`); + throw err; + } + }, [state, addLog]); + + // Disconnect from the MCP server + const disconnect = useCallback(async () => { + if (clientRef.current) { + try { + await clientRef.current.close(); + } catch (err) { + addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`); + } + clientRef.current = null; + } + + if (transportRef.current) { + try { + await transportRef.current.close(); + } catch (err) { + addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`); + } + transportRef.current = null; + } + + connectingRef.current = false; + setState('discovering'); + setTools([]); + setError(undefined); + }, [addLog]); + let handleAuthentication: () => Promise; + + // Initialize connection to MCP server + const connect = useCallback(async () => { + // Prevent multiple simultaneous connection attempts + if (connectingRef.current) return; + connectingRef.current = true; + + try { + setState('discovering'); + setError(undefined); + + // Create auth provider if not already created + if (!authProviderRef.current) { + authProviderRef.current = new BrowserOAuthClientProvider(url, { + storageKeyPrefix, + clientName, + clientUri, + callbackUrl, + }); + } + + // Discover OAuth metadata if not already discovered + if (!metadataRef.current) { + addLog('info', 'Discovering OAuth metadata...'); + metadataRef.current = await discoverOAuthMetadata(url); + addLog('debug', `OAuth metadata: ${metadataRef.current ? 'Found' : 'Not available'}`); + } + + // Create MCP client + clientRef.current = new Client( + { + name: clientConfig.name || "mcp-react-client", + version: clientConfig.version || "0.1.0", + }, + { + capabilities: { + sampling: {}, + }, + } + ); + + // Set up auth flow - check if we have tokens + const tokens = await authProviderRef.current.tokens(); + + // Create SSE transport + setState('connecting'); + addLog('info', 'Creating transport...'); + + const serverUrl = new URL(url); + transportRef.current = new SSEClientTransport(serverUrl, { + authProvider: authProviderRef.current + }); + + // Set up transport handlers + transportRef.current.onmessage = (message: JSONRPCMessage) => { + addLog('debug', `Received message: ${message.method || message.id}`); + }; + + transportRef.current.onerror = (err: Error) => { + addLog('error', `Transport error: ${err.message}`); + + if (err.message.includes('Unauthorized')) { + setState('authenticating'); + handleAuthentication().catch(authErr => { + addLog('error', `Authentication error: ${authErr.message}`); + setState('failed'); + setError(`Authentication failed: ${authErr.message}`); + connectingRef.current = false; + }); + } else { + setState('failed'); + setError(`Connection error: ${err.message}`); + connectingRef.current = false; + } + }; + + transportRef.current.onclose = () => { + addLog('info', 'Connection closed'); + // If we were previously connected, try to reconnect + if (state === 'ready' && autoReconnect) { + const delay = typeof autoReconnect === 'number' ? autoReconnect : 3000; + addLog('info', `Will reconnect in ${delay}ms...`); + setTimeout(() => { + disconnect().then(() => connect()); + }, delay); + } + }; + + // Connect transport + try { + addLog('info', 'Starting transport...'); + await transportRef.current.start(); + } catch (err) { + addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`); + + if (err instanceof Error && err.message.includes('Unauthorized')) { + setState('authenticating'); + // Start authentication process + await handleAuthentication(); + // After successful auth, retry connection + return connect(); + } else { + setState('failed'); + setError(`Connection error: ${err instanceof Error ? err.message : String(err)}`); + connectingRef.current = false; + return; + } + } + + // Connect client + try { + addLog('info', 'Connecting client...'); + setState('loading'); + await clientRef.current.connect(transportRef.current); + addLog('info', 'Client connected'); + + // Load tools + try { + addLog('info', 'Loading tools...'); + const toolsResponse = await clientRef.current.request( + { method: "tools/list" }, + ListToolsResultSchema + ); + setTools(toolsResponse.tools); + addLog('info', `Loaded ${toolsResponse.tools.length} tools`); + + // Connection completed successfully + setState('ready'); + connectingRef.current = false; + } catch (toolErr) { + addLog('error', `Error loading tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`); + // We're still connected, just couldn't load tools + setState('ready'); + connectingRef.current = false; + } + } catch (connectErr) { + addLog('error', `Client connect error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`); + setState('failed'); + setError(`Connection error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`); + connectingRef.current = false; + } + } catch (err) { + addLog('error', `Unexpected error: ${err instanceof Error ? err.message : String(err)}`); + setState('failed'); + setError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`); + connectingRef.current = false; + } + }, [url, clientName, clientUri, callbackUrl, storageKeyPrefix, clientConfig, debug, autoReconnect, addLog, handleAuthentication, disconnect]); + + // Handle authentication flow + handleAuthentication = useCallback(async () => { + if (!authProviderRef.current || !metadataRef.current) { + throw new Error("Auth provider or metadata not available"); + } + + addLog('info', 'Starting authentication flow...'); + + // Check if we have client info + let clientInfo = await authProviderRef.current.clientInformation(); + + if (!clientInfo) { + // Register client dynamically + addLog('info', 'No client information found, registering...'); + // Note: In a complete implementation, you'd register the client here + // This would be done server-side in a real application + throw new Error("Dynamic client registration not implemented in this example"); + } + + // Start authorization flow + addLog('info', 'Starting authorization...'); + const {authorizationUrl, codeVerifier} = await startAuthorization(url, { + metadata: metadataRef.current, + clientInformation: clientInfo, + redirectUrl: authProviderRef.current.redirectUrl + }); + + // Save code verifier + await authProviderRef.current.saveCodeVerifier(codeVerifier); + + // Set up listener for post-auth message + const authPromise = new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + window.removeEventListener('message', messageHandler); + reject(new Error("Authentication timeout after 5 minutes")); + }, 5 * 60 * 1000); + + const messageHandler = (event: MessageEvent) => { + // Verify origin for security + if (event.origin !== window.location.origin) return; + + if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { + window.removeEventListener('message', messageHandler); + clearTimeout(timeoutId); + resolve(event.data.code); + } + }; + + window.addEventListener('message', messageHandler); + }); + + // Redirect to authorization + await authProviderRef.current.redirectToAuthorization(authorizationUrl); + + // Wait for auth to complete + addLog('info', 'Waiting for authorization...'); + const code = await authPromise; + addLog('info', 'Authorization code received'); + + return code; + }, [url, addLog]); + + // Handle auth completion - this is called when we receive a message from the popup + const handleAuthCompletion = useCallback(async (code: string) => { + if (!authProviderRef.current || !transportRef.current || !metadataRef.current) { + throw new Error("Authentication context not available"); + } + + try { + addLog('info', 'Finishing authorization...'); + await transportRef.current.finishAuth(code); + addLog('info', 'Authorization completed'); + + // Reconnect with the new auth token + await disconnect(); + connect(); + } catch (err) { + addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`); + setState('failed'); + setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`); + } + }, [addLog, disconnect, connect]); + + // Retry connection + const retry = useCallback(() => { + if (state === 'failed') { + disconnect().then(() => connect()); + } + }, [state, disconnect, connect]); + + // Set up message listener for auth callback + useEffect(() => { + const messageHandler = (event: MessageEvent) => { + // Verify origin for security + if (event.origin !== window.location.origin) return; + + if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { + handleAuthCompletion(event.data.code).catch(err => { + addLog('error', `Auth callback error: ${err.message}`); + }); + } + }; + + window.addEventListener('message', messageHandler); + return () => { + window.removeEventListener('message', messageHandler); + }; + }, [handleAuthCompletion, addLog]); + + // Initial connection and auto-retry + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false; + connect(); + } else if (state === 'failed' && autoRetry) { + const delay = typeof autoRetry === 'number' ? autoRetry : 5000; + const timeoutId = setTimeout(() => { + addLog('info', 'Auto-retrying connection...'); + disconnect().then(() => connect()); + }, delay); + + return () => { + clearTimeout(timeoutId); + }; + } + }, [state, autoRetry, connect, disconnect, addLog]); + + // Clean up on unmount + useEffect(() => { + return () => { + if (clientRef.current || transportRef.current) { + disconnect(); + } + }; + }, [disconnect]); + + return { + state, + tools, + error, + log, + callTool, + retry, + disconnect + }; } /** @@ -51,5 +567,127 @@ export function useMcp( * window to inform any running `useMcp` hooks that the auth flow is complete. */ export async function onMcpAuthorization(query: Record) { - // TODO: implement + try { + // Extract the authorization code and state + const code = query.code; + const state = query.state; + + if (!code) { + throw new Error("No authorization code received"); + } + + if (!state) { + throw new Error("No state parameter received"); + } + + // Find the matching auth state in localStorage + const storageKeys = Object.keys(localStorage).filter(key => + key.includes('_auth_state') && localStorage.getItem(key) === state + ); + + if (storageKeys.length === 0) { + throw new Error("No matching auth state found in storage"); + } + + const storageKey = storageKeys[0]; + const keyParts = storageKey.split('_'); + const serverUrlHash = keyParts[1]; + const storageKeyPrefix = keyParts[0]; + + // Find all related auth data with the same prefix and server hash + const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info`; + const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier`; + + const clientInfoStr = localStorage.getItem(clientInfoKey); + const codeVerifier = localStorage.getItem(codeVerifierKey); + + if (!clientInfoStr) { + throw new Error("No client information found in storage"); + } + + if (!codeVerifier) { + throw new Error("No code verifier found in storage"); + } + + // Parse client info + const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation; + + // Find the server URL from other keys in localStorage + const serverUrlKeys = Object.keys(localStorage).filter(key => + key.startsWith(`${storageKeyPrefix}_server_`) && key.includes(serverUrlHash) + ); + + let serverUrl: string; + if (serverUrlKeys.length > 0) { + serverUrl = localStorage.getItem(serverUrlKeys[0]) || ''; + } else { + // If we can't find the server URL, try to construct it from the current URL + // This is a fallback and may not always work + const currentUrl = new URL(window.location.href); + serverUrl = `${currentUrl.protocol}//${currentUrl.host}`; + } + + if (!serverUrl) { + throw new Error("Could not determine server URL"); + } + + // Exchange the code for tokens + const metadata = await discoverOAuthMetadata(serverUrl); + + const tokens = await exchangeAuthorization(serverUrl, { + metadata, + clientInformation: clientInfo, + authorizationCode: code, + codeVerifier, + }); + + // Save the tokens + const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens`; + localStorage.setItem(tokensKey, JSON.stringify(tokens)); + + // Post message back to the parent window + if (window.opener && !window.opener.closed) { + window.opener.postMessage({ + type: 'mcp_auth_callback', + code + }, window.location.origin); + // Close the popup + window.close(); + } else { + // If no parent window, we're in a redirect flow + // Redirect back to the main page + window.location.href = '/'; + } + + return { success: true }; + } catch (error) { + console.error('Error in MCP authorization:', error); + + // Create a readable error message for display + const errorMessage = error instanceof Error ? error.message : String(error); + + // If the popup is still open, show the error + const errorHtml = ` + + + Authentication Error + + + +

Authentication Error

+
+

${errorMessage}

+
+

You can close this window and try again.

+ + + `; + + document.body.innerHTML = errorHtml; + + return { success: false, error: errorMessage }; + } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e66a602..b562002 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,8 +7,9 @@ "rootDir": "./src", "strict": true, "esModuleInterop": true, - "lib": ["ES2022"], - "types": ["node"], + + "lib": ["ES2022", "DOM"], + "types": ["node", "react"], "forceConsistentCasingInFileNames": true, "resolveJsonModule": true }, diff --git a/tsup.config.ts b/tsup.config.ts index 68da9d3..a203cbb 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -6,5 +6,5 @@ export default defineConfig({ dts: true, clean: true, outDir: 'dist', - // external: ['typescript'], + external: ['react'], }) From 542a66951ce4766f2bfb040a7ad614e3fef222e8 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 24 Mar 2025 11:07:44 +1100 Subject: [PATCH 3/5] wip --- src/react/index.ts | 812 +++++++++++++++++++++++++++------------------ 1 file changed, 494 insertions(+), 318 deletions(-) diff --git a/src/react/index.ts b/src/react/index.ts index d3408b2..a933982 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,177 +1,259 @@ -import { Tool, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; -import { useCallback, useEffect, useState, useRef } from "react"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; -import { discoverOAuthMetadata, startAuthorization, exchangeAuthorization } from "@modelcontextprotocol/sdk/client/auth.js"; -import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { Tool, OAuthMetadata, JSONRPCMessage, OAuthClientInformation, OAuthTokens } from '@modelcontextprotocol/sdk/types.js' +import { useCallback, useEffect, useState, useRef } from 'react' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' +import { discoverOAuthMetadata, startAuthorization, exchangeAuthorization } from '@modelcontextprotocol/sdk/client/auth.js' export type UseMcpOptions = { /** The /sse URL of your remote MCP server */ - url: string, + url: string /** OAuth client name for registration */ - clientName?: string, + clientName?: string /** OAuth client URI for registration */ - clientUri?: string, + clientUri?: string /** Custom callback URL for OAuth redirect (defaults to /oauth/callback on the current origin) */ - callbackUrl?: string, + callbackUrl?: string /** Storage key prefix for OAuth data (defaults to "mcp_auth") */ - storageKeyPrefix?: string, + storageKeyPrefix?: string /** Custom configuration for the MCP client */ clientConfig?: { - name?: string, - version?: string, - }, + name?: string + version?: string + } /** Whether to enable debug logging */ - debug?: boolean, + debug?: boolean /** Auto retry connection if it fails, with delay in ms (default: false) */ - autoRetry?: boolean | number, + autoRetry?: boolean | number /** Auto reconnect if connection is lost, with delay in ms (default: 3000) */ - autoReconnect?: boolean | number, + autoReconnect?: boolean | number + /** OAuth authentication mode (default: 'popup-with-redirect-fallback') */ + authMode?: 'popup-only' | 'redirect-only' | 'popup-with-redirect-fallback' + /** Popup window features (dimensions and behavior) for OAuth */ + popupFeatures?: string } export type UseMcpResult = { - tools: Tool[], + tools: Tool[] /** * The current state of the MCP connection. This will be one of: * - 'discovering': Finding out whether there is in fact a server at that URL, and what its capabilities are * - 'authenticating': The server has indicated we must authenticate, so we can't proceed until that's complete + * - 'popup-blocked': The auth popup was blocked by the browser, manual authentication is needed * - 'connecting': The connection to the MCP server is being established. This happens before we know whether we need to authenticate or not, and then again once we have credentials * - 'loading': We're connected to the MCP server, and now we're loading its resources/prompts/tools * - 'ready': The MCP server is connected and ready to be used * - 'failed': The connection to the MCP server failed * */ - state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed', + state: 'discovering' | 'authenticating' | 'popup-blocked' | 'connecting' | 'loading' | 'ready' | 'failed' /** If the state is 'failed', this will be the error message */ - error?: string, + error?: string /** All internal log messages */ - log: {level: 'debug' | 'info' | 'warn' | 'error', message: string}[], + log: { level: 'debug' | 'info' | 'warn' | 'error'; message: string }[] /** Call a tool on the MCP server */ - callTool: (name: string, args?: Record) => Promise, + callTool: (name: string, args?: Record) => Promise /** Manually retry connection if it's in a failed state */ - retry: () => void, + retry: () => void /** Manually disconnect from the MCP server */ - disconnect: () => void, + disconnect: () => void + /** + * Manually trigger authentication (useful when popup is blocked) + * @returns Auth URL that can be used to manually open a new window + */ + authenticate: () => Promise + /** Authentication URL to manually open if popup is blocked */ + authUrl?: string } /** * Browser-compatible OAuth client provider for MCP */ class BrowserOAuthClientProvider { - private storageKeyPrefix: string; - private serverUrlHash: string; - private clientName: string; - private clientUri: string; - private callbackUrl: string; + private storageKeyPrefix: string + private serverUrlHash: string + private clientName: string + private clientUri: string + private callbackUrl: string constructor( readonly serverUrl: string, options: { - storageKeyPrefix?: string; - clientName?: string; - clientUri?: string; - callbackUrl?: string; - } = {} + storageKeyPrefix?: string + clientName?: string + clientUri?: string + callbackUrl?: string + } = {}, ) { - this.storageKeyPrefix = options.storageKeyPrefix || "mcp_auth"; - this.serverUrlHash = this.hashString(serverUrl); - this.clientName = options.clientName || "MCP Browser Client"; - this.clientUri = options.clientUri || window.location.origin; - this.callbackUrl = options.callbackUrl || new URL("/oauth/callback", window.location.origin).toString(); + this.storageKeyPrefix = options.storageKeyPrefix || 'mcp_auth' + this.serverUrlHash = this.hashString(serverUrl) + this.clientName = options.clientName || 'MCP Browser Client' + this.clientUri = options.clientUri || window.location.origin + this.callbackUrl = options.callbackUrl || new URL('/oauth/callback', window.location.origin).toString() } get redirectUrl(): string { - return this.callbackUrl; + return this.callbackUrl } get clientMetadata() { return { redirect_uris: [this.redirectUrl], - token_endpoint_auth_method: "none", - grant_types: ["authorization_code", "refresh_token"], - response_types: ["code"], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], client_name: this.clientName, client_uri: this.clientUri, - }; + } } private hashString(str: string): string { // Simple hash function for browser environments - let hash = 0; + let hash = 0 for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer + const char = str.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer } - return Math.abs(hash).toString(16); + return Math.abs(hash).toString(16) } private getKey(key: string): string { - return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}`; + return `${this.storageKeyPrefix}_${this.serverUrlHash}_${key}` } async clientInformation(): Promise { - const key = this.getKey("client_info"); - const data = localStorage.getItem(key); - if (!data) return undefined; + const key = this.getKey('client_info') + const data = localStorage.getItem(key) + if (!data) return undefined try { - return JSON.parse(data) as OAuthClientInformation; + return JSON.parse(data) as OAuthClientInformation } catch (e) { - return undefined; + return undefined } } async saveClientInformation(clientInformation: OAuthClientInformation): Promise { - const key = this.getKey("client_info"); - localStorage.setItem(key, JSON.stringify(clientInformation)); + const key = this.getKey('client_info') + localStorage.setItem(key, JSON.stringify(clientInformation)) } async tokens(): Promise { - const key = this.getKey("tokens"); - const data = localStorage.getItem(key); - if (!data) return undefined; + const key = this.getKey('tokens') + const data = localStorage.getItem(key) + if (!data) return undefined try { - return JSON.parse(data) as OAuthTokens; + return JSON.parse(data) as OAuthTokens } catch (e) { - return undefined; + return undefined } } async saveTokens(tokens: OAuthTokens): Promise { - const key = this.getKey("tokens"); - localStorage.setItem(key, JSON.stringify(tokens)); + const key = this.getKey('tokens') + localStorage.setItem(key, JSON.stringify(tokens)) } - async redirectToAuthorization(authorizationUrl: URL): Promise { + async redirectToAuthorization( + authorizationUrl: URL, + options?: { + mode?: 'popup-only' | 'redirect-only' | 'popup-with-redirect-fallback' + popupFeatures?: string + }, + ): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> { // Store the auth state for the popup flow - const stateKey = this.getKey("auth_state"); - const state = Math.random().toString(36).substring(2); - localStorage.setItem(stateKey, state); - authorizationUrl.searchParams.set("state", state); + const stateKey = this.getKey('auth_state') + const state = Math.random().toString(36).substring(2) + localStorage.setItem(stateKey, state) + authorizationUrl.searchParams.set('state', state) - // Open the authorization URL in a popup window - const popup = window.open(authorizationUrl.toString(), "mcp_auth", "width=600,height=700"); + const authUrl = authorizationUrl.toString() + const mode = options?.mode || 'popup-only' + const popupFeatures = options?.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes' - if (!popup || popup.closed) { - console.warn("Popup blocked. Redirecting in the same window..."); - window.location.href = authorizationUrl.toString(); + // Store the auth URL in case we need it for manual authentication + localStorage.setItem(this.getKey('auth_url'), authUrl) + console.log({ mode }) + + if (mode === 'redirect-only') { + // Redirect in the current window + window.location.href = authUrl + return { success: true, url: authUrl } } + + if (mode === 'popup-only' || mode === 'popup-with-redirect-fallback') { + try { + // Open the authorization URL in a popup window + const popup = window.open(authUrl, 'mcp_auth', popupFeatures) + console.log({ popup }) + + // Check if popup was blocked or closed immediately + if (!popup || popup.closed || popup.closed === undefined) { + if (mode === 'popup-with-redirect-fallback') { + // Fall back to redirect + console.warn('Popup blocked. Redirecting in the same window...') + window.location.href = authUrl + return { success: true, popupBlocked: true, url: authUrl } + } else { + // Popup-only mode, return error + console.warn('Popup blocked and redirect fallback disabled') + return { success: false, popupBlocked: true, url: authUrl } + } + } + + // Try to access the popup to confirm it's not blocked + try { + // Just accessing any property will throw if popup is blocked + const popupLocation = popup.location + // If we can read location.href, the popup is definitely working + if (popupLocation.href) { + // Successfully opened popup + return { success: true, url: authUrl } + } + } catch (e) { + // Access to the popup was denied, indicating it's blocked + if (mode === 'popup-with-redirect-fallback') { + console.warn('Popup blocked (security exception). Redirecting in the same window...') + window.location.href = authUrl + return { success: true, popupBlocked: true, url: authUrl } + } else { + console.warn('Popup blocked (security exception) and redirect fallback disabled') + return { success: false, popupBlocked: true, url: authUrl } + } + } + + // If we got here, popup is working + return { success: true, url: authUrl } + } catch (e) { + // Error opening popup + if (mode === 'popup-with-redirect-fallback') { + console.warn('Error opening popup:', e, 'Falling back to redirect') + window.location.href = authUrl + return { success: true, popupBlocked: true, url: authUrl } + } else { + console.warn('Error opening popup:', e, 'and redirect fallback disabled') + return { success: false, popupBlocked: true, url: authUrl } + } + } + } + + // This shouldn't happen given the enum constraint, but TypeScript doesn't know that + throw new Error(`Invalid auth mode: ${mode}`) } async saveCodeVerifier(codeVerifier: string): Promise { - const key = this.getKey("code_verifier"); - localStorage.setItem(key, codeVerifier); + const key = this.getKey('code_verifier') + localStorage.setItem(key, codeVerifier) } async codeVerifier(): Promise { - const key = this.getKey("code_verifier"); - const verifier = localStorage.getItem(key); + const key = this.getKey('code_verifier') + const verifier = localStorage.getItem(key) if (!verifier) { - throw new Error("No code verifier found in storage"); + throw new Error('No code verifier found in storage') } - return verifier; + return verifier } } @@ -181,96 +263,106 @@ class BrowserOAuthClientProvider { * and enables passing a list of tools (once loaded) to ai-sdk (using `useChat`). */ export function useMcp(options: UseMcpOptions): UseMcpResult { - const [state, setState] = useState('discovering'); - const [tools, setTools] = useState([]); - const [error, setError] = useState(undefined); - const [log, setLog] = useState([]); + const [state, setState] = useState('discovering') + const [tools, setTools] = useState([]) + const [error, setError] = useState(undefined) + const [log, setLog] = useState([]) + const [authUrl, setAuthUrl] = useState(undefined) - const clientRef = useRef(null); - const transportRef = useRef(null); - const authProviderRef = useRef(null); - const metadataRef = useRef(undefined); - const connectingRef = useRef(false); - const isInitialMount = useRef(true); + const clientRef = useRef(null) + const transportRef = useRef(null) + const authProviderRef = useRef(null) + const metadataRef = useRef(undefined) + const authUrlRef = useRef(undefined) + const codeVerifierRef = useRef(undefined) + const connectingRef = useRef(false) + const isInitialMount = useRef(true) + let handleAuthentication: () => Promise // Set up default options const { url, - clientName = "MCP React Client", + clientName = 'MCP React Client', clientUri = window.location.origin, - callbackUrl = new URL("/oauth/callback", window.location.origin).toString(), - storageKeyPrefix = "mcp_auth", + callbackUrl = new URL('/oauth/callback', window.location.origin).toString(), + storageKeyPrefix = 'mcp_auth', clientConfig = { - name: "mcp-react-client", - version: "0.1.0", + name: 'mcp-react-client', + version: '0.1.0', }, debug = false, autoRetry = false, autoReconnect = 3000, - } = options; + authMode = 'popup-only', + popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes', + } = options // Add to log - const addLog = useCallback((level: 'debug' | 'info' | 'warn' | 'error', message: string) => { - if (level === 'debug' && !debug) return; - setLog(prevLog => [...prevLog, { level, message }]); - }, [debug]); + const addLog = useCallback( + (level: 'debug' | 'info' | 'warn' | 'error', message: string) => { + console.log(message) + if (level === 'debug' && !debug) return + setLog((prevLog) => [...prevLog, { level, message }]) + }, + [debug], + ) // Call a tool on the MCP server - const callTool = useCallback(async (name: string, args?: Record) => { - if (!clientRef.current || state !== 'ready') { - throw new Error("MCP client not ready"); - } + const callTool = useCallback( + async (name: string, args?: Record) => { + if (!clientRef.current || state !== 'ready') { + throw new Error('MCP client not ready') + } - try { - const result = await clientRef.current.request( - { - method: "tools/call", + try { + const result = await clientRef.current.request({ + method: 'tools/call', params: { name, arguments: args }, - } - ); - return result; - } catch (err) { - addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`); - throw err; - } - }, [state, addLog]); + }) + return result + } catch (err) { + addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`) + throw err + } + }, + [state, addLog], + ) // Disconnect from the MCP server const disconnect = useCallback(async () => { if (clientRef.current) { try { - await clientRef.current.close(); + await clientRef.current.close() } catch (err) { - addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`); + addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`) } - clientRef.current = null; + clientRef.current = null } if (transportRef.current) { try { - await transportRef.current.close(); + await transportRef.current.close() } catch (err) { - addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`); + addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`) } - transportRef.current = null; + transportRef.current = null } - connectingRef.current = false; - setState('discovering'); - setTools([]); - setError(undefined); - }, [addLog]); - let handleAuthentication: () => Promise; + connectingRef.current = false + setState('discovering') + setTools([]) + setError(undefined) + }, [addLog]) // Initialize connection to MCP server const connect = useCallback(async () => { // Prevent multiple simultaneous connection attempts - if (connectingRef.current) return; - connectingRef.current = true; + if (connectingRef.current) return + connectingRef.current = true try { - setState('discovering'); - setError(undefined); + setState('discovering') + setError(undefined) // Create auth provider if not already created if (!authProviderRef.current) { @@ -279,282 +371,365 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { clientName, clientUri, callbackUrl, - }); + }) } // Discover OAuth metadata if not already discovered if (!metadataRef.current) { - addLog('info', 'Discovering OAuth metadata...'); - metadataRef.current = await discoverOAuthMetadata(url); - addLog('debug', `OAuth metadata: ${metadataRef.current ? 'Found' : 'Not available'}`); + addLog('info', 'Discovering OAuth metadata...') + metadataRef.current = await discoverOAuthMetadata(url) + addLog('debug', `OAuth metadata: ${metadataRef.current ? 'Found' : 'Not available'}`) } // Create MCP client clientRef.current = new Client( { - name: clientConfig.name || "mcp-react-client", - version: clientConfig.version || "0.1.0", + name: clientConfig.name || 'mcp-react-client', + version: clientConfig.version || '0.1.0', }, { capabilities: { sampling: {}, }, - } - ); + }, + ) // Set up auth flow - check if we have tokens - const tokens = await authProviderRef.current.tokens(); + const tokens = await authProviderRef.current.tokens() // Create SSE transport - setState('connecting'); - addLog('info', 'Creating transport...'); + setState('connecting') + addLog('info', 'Creating transport...') - const serverUrl = new URL(url); + const serverUrl = new URL(url) transportRef.current = new SSEClientTransport(serverUrl, { - authProvider: authProviderRef.current - }); + authProvider: authProviderRef.current, + }) // Set up transport handlers transportRef.current.onmessage = (message: JSONRPCMessage) => { - addLog('debug', `Received message: ${message.method || message.id}`); - }; + addLog('debug', `Received message: ${message.method || message.id}`) + } transportRef.current.onerror = (err: Error) => { - addLog('error', `Transport error: ${err.message}`); + addLog('error', `Transport error: ${err.message}`) if (err.message.includes('Unauthorized')) { - setState('authenticating'); - handleAuthentication().catch(authErr => { - addLog('error', `Authentication error: ${authErr.message}`); - setState('failed'); - setError(`Authentication failed: ${authErr.message}`); - connectingRef.current = false; - }); + setState('authenticating') + handleAuthentication().catch((authErr) => { + addLog('error', `Authentication error: ${authErr.message}`) + setState('failed') + setError(`Authentication failed: ${authErr.message}`) + connectingRef.current = false + }) } else { - setState('failed'); - setError(`Connection error: ${err.message}`); - connectingRef.current = false; + setState('failed') + setError(`Connection error: ${err.message}`) + connectingRef.current = false } - }; + } transportRef.current.onclose = () => { - addLog('info', 'Connection closed'); + addLog('info', 'Connection closed') // If we were previously connected, try to reconnect if (state === 'ready' && autoReconnect) { - const delay = typeof autoReconnect === 'number' ? autoReconnect : 3000; - addLog('info', `Will reconnect in ${delay}ms...`); + const delay = typeof autoReconnect === 'number' ? autoReconnect : 3000 + addLog('info', `Will reconnect in ${delay}ms...`) setTimeout(() => { - disconnect().then(() => connect()); - }, delay); + disconnect().then(() => connect()) + }, delay) } - }; + } // Connect transport try { - addLog('info', 'Starting transport...'); - await transportRef.current.start(); + addLog('info', 'Starting transport...') + await transportRef.current.start() } catch (err) { - addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`); + addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`) if (err instanceof Error && err.message.includes('Unauthorized')) { - setState('authenticating'); + setState('authenticating') // Start authentication process - await handleAuthentication(); + await handleAuthentication() // After successful auth, retry connection - return connect(); + return connect() } else { - setState('failed'); - setError(`Connection error: ${err instanceof Error ? err.message : String(err)}`); - connectingRef.current = false; - return; + setState('failed') + setError(`Connection error: ${err instanceof Error ? err.message : String(err)}`) + connectingRef.current = false + return } } // Connect client try { - addLog('info', 'Connecting client...'); - setState('loading'); - await clientRef.current.connect(transportRef.current); - addLog('info', 'Client connected'); + addLog('info', 'Connecting client...') + setState('loading') + await clientRef.current.connect(transportRef.current) + addLog('info', 'Client connected') // Load tools try { - addLog('info', 'Loading tools...'); - const toolsResponse = await clientRef.current.request( - { method: "tools/list" }, - ListToolsResultSchema - ); - setTools(toolsResponse.tools); - addLog('info', `Loaded ${toolsResponse.tools.length} tools`); + addLog('info', 'Loading tools...') + const toolsResponse = await clientRef.current.request({ method: 'tools/list' }, ListToolsResultSchema) + setTools(toolsResponse.tools) + addLog('info', `Loaded ${toolsResponse.tools.length} tools`) // Connection completed successfully - setState('ready'); - connectingRef.current = false; + setState('ready') + connectingRef.current = false } catch (toolErr) { - addLog('error', `Error loading tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`); + addLog('error', `Error loading tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`) // We're still connected, just couldn't load tools - setState('ready'); - connectingRef.current = false; + setState('ready') + connectingRef.current = false } } catch (connectErr) { - addLog('error', `Client connect error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`); - setState('failed'); - setError(`Connection error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`); - connectingRef.current = false; + addLog('error', `Client connect error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`) + setState('failed') + setError(`Connection error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`) + connectingRef.current = false } } catch (err) { - addLog('error', `Unexpected error: ${err instanceof Error ? err.message : String(err)}`); - setState('failed'); - setError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`); - connectingRef.current = false; + addLog('error', `Unexpected error: ${err instanceof Error ? err.message : String(err)}`) + setState('failed') + setError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`) + connectingRef.current = false } - }, [url, clientName, clientUri, callbackUrl, storageKeyPrefix, clientConfig, debug, autoReconnect, addLog, handleAuthentication, disconnect]); + }, [ + url, + clientName, + clientUri, + callbackUrl, + storageKeyPrefix, + clientConfig, + debug, + autoReconnect, + addLog, + handleAuthentication, + disconnect, + ]) - // Handle authentication flow - handleAuthentication = useCallback(async () => { + // Provide public authenticate method + const authenticate = useCallback(async (): Promise => { + if (!authUrlRef.current) { + await startAuthFlow() + } + + if (authUrlRef.current) { + return authUrlRef.current.toString() + } + return undefined + }, []) + + // Start the auth flow and get the auth URL + const startAuthFlow = useCallback(async (): Promise => { if (!authProviderRef.current || !metadataRef.current) { - throw new Error("Auth provider or metadata not available"); + throw new Error('Auth provider or metadata not available') } - addLog('info', 'Starting authentication flow...'); + addLog('info', 'Starting authentication flow...') // Check if we have client info - let clientInfo = await authProviderRef.current.clientInformation(); + let clientInfo = await authProviderRef.current.clientInformation() if (!clientInfo) { // Register client dynamically - addLog('info', 'No client information found, registering...'); + addLog('info', 'No client information found, registering...') // Note: In a complete implementation, you'd register the client here // This would be done server-side in a real application - throw new Error("Dynamic client registration not implemented in this example"); + throw new Error('Dynamic client registration not implemented in this example') } // Start authorization flow - addLog('info', 'Starting authorization...'); - const {authorizationUrl, codeVerifier} = await startAuthorization(url, { + addLog('info', 'Preparing authorization...') + const { authorizationUrl, codeVerifier } = await startAuthorization(url, { metadata: metadataRef.current, clientInformation: clientInfo, - redirectUrl: authProviderRef.current.redirectUrl - }); + redirectUrl: authProviderRef.current.redirectUrl, + }) - // Save code verifier - await authProviderRef.current.saveCodeVerifier(codeVerifier); + // Save code verifier and auth URL for later use + await authProviderRef.current.saveCodeVerifier(codeVerifier) + codeVerifierRef.current = codeVerifier + authUrlRef.current = authorizationUrl + setAuthUrl(authorizationUrl.toString()) + + return authorizationUrl + }, [url, addLog]) + + // Handle authentication flow + handleAuthentication = useCallback(async () => { + if (!authProviderRef.current) { + throw new Error('Auth provider not available') + } + + // Get or create the auth URL + if (!authUrlRef.current) { + try { + await startAuthFlow() + } catch (err) { + addLog('error', `Failed to start auth flow: ${err instanceof Error ? err.message : String(err)}`) + throw err + } + } + + if (!authUrlRef.current) { + throw new Error('Failed to create authorization URL') + } // Set up listener for post-auth message const authPromise = new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - window.removeEventListener('message', messageHandler); - reject(new Error("Authentication timeout after 5 minutes")); - }, 5 * 60 * 1000); + const timeoutId = setTimeout( + () => { + window.removeEventListener('message', messageHandler) + reject(new Error('Authentication timeout after 5 minutes')) + }, + 5 * 60 * 1000, + ) const messageHandler = (event: MessageEvent) => { // Verify origin for security - if (event.origin !== window.location.origin) return; + if (event.origin !== window.location.origin) return if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { - window.removeEventListener('message', messageHandler); - clearTimeout(timeoutId); - resolve(event.data.code); + window.removeEventListener('message', messageHandler) + clearTimeout(timeoutId) + resolve(event.data.code) } - }; + } - window.addEventListener('message', messageHandler); - }); + window.addEventListener('message', messageHandler) + }) // Redirect to authorization - await authProviderRef.current.redirectToAuthorization(authorizationUrl); + addLog('info', 'Opening authorization window...') + const redirectResult = await authProviderRef.current.redirectToAuthorization(authUrlRef.current, { + mode: authMode, + popupFeatures, + }) + + if (!redirectResult.success) { + // Popup was blocked and we're in popup-only mode + setState('popup-blocked') + setError('Authentication popup was blocked by the browser') + addLog('warn', 'Authentication popup was blocked. User needs to manually authorize.') + throw new Error('Authentication popup blocked') + } + + if (redirectResult.popupBlocked && authMode === 'popup-with-redirect-fallback') { + // The popup was blocked but we've fallen back to redirect + // No need to wait for the auth promise since we're redirecting + addLog('info', 'Popup blocked, falling back to redirect...') + return 'redirect-in-progress' + } // Wait for auth to complete - addLog('info', 'Waiting for authorization...'); - const code = await authPromise; - addLog('info', 'Authorization code received'); + addLog('info', 'Waiting for authorization...') + const code = await authPromise + addLog('info', 'Authorization code received') - return code; - }, [url, addLog]); + return code + }, [url, addLog, authMode, popupFeatures, startAuthFlow]) // Handle auth completion - this is called when we receive a message from the popup - const handleAuthCompletion = useCallback(async (code: string) => { - if (!authProviderRef.current || !transportRef.current || !metadataRef.current) { - throw new Error("Authentication context not available"); - } + const handleAuthCompletion = useCallback( + async (code: string) => { + if (!authProviderRef.current || !transportRef.current) { + throw new Error('Authentication context not available') + } - try { - addLog('info', 'Finishing authorization...'); - await transportRef.current.finishAuth(code); - addLog('info', 'Authorization completed'); + try { + addLog('info', 'Finishing authorization...') + await transportRef.current.finishAuth(code) + addLog('info', 'Authorization completed') - // Reconnect with the new auth token - await disconnect(); - connect(); - } catch (err) { - addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`); - setState('failed'); - setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`); - } - }, [addLog, disconnect, connect]); + // Reset auth URL state + authUrlRef.current = undefined + setAuthUrl(undefined) + + // Reset popup blocked state if we were in that state + if (state === 'popup-blocked') { + setState('authenticating') + } + + // Reconnect with the new auth token + await disconnect() + connect() + } catch (err) { + addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`) + setState('failed') + setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } + }, + [addLog, disconnect, connect, state], + ) // Retry connection const retry = useCallback(() => { if (state === 'failed') { - disconnect().then(() => connect()); + disconnect().then(() => connect()) } - }, [state, disconnect, connect]); + }, [state, disconnect, connect]) // Set up message listener for auth callback useEffect(() => { const messageHandler = (event: MessageEvent) => { // Verify origin for security - if (event.origin !== window.location.origin) return; + if (event.origin !== window.location.origin) return if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { - handleAuthCompletion(event.data.code).catch(err => { - addLog('error', `Auth callback error: ${err.message}`); - }); + handleAuthCompletion(event.data.code).catch((err) => { + addLog('error', `Auth callback error: ${err.message}`) + }) } - }; + } - window.addEventListener('message', messageHandler); + window.addEventListener('message', messageHandler) return () => { - window.removeEventListener('message', messageHandler); - }; - }, [handleAuthCompletion, addLog]); + window.removeEventListener('message', messageHandler) + } + }, [handleAuthCompletion, addLog]) // Initial connection and auto-retry useEffect(() => { if (isInitialMount.current) { - isInitialMount.current = false; - connect(); + isInitialMount.current = false + connect() } else if (state === 'failed' && autoRetry) { - const delay = typeof autoRetry === 'number' ? autoRetry : 5000; + const delay = typeof autoRetry === 'number' ? autoRetry : 5000 const timeoutId = setTimeout(() => { - addLog('info', 'Auto-retrying connection...'); - disconnect().then(() => connect()); - }, delay); + addLog('info', 'Auto-retrying connection...') + disconnect().then(() => connect()) + }, delay) return () => { - clearTimeout(timeoutId); - }; + clearTimeout(timeoutId) + } } - }, [state, autoRetry, connect, disconnect, addLog]); + }, [state, autoRetry, connect, disconnect, addLog]) // Clean up on unmount useEffect(() => { return () => { if (clientRef.current || transportRef.current) { - disconnect(); + disconnect() } - }; - }, [disconnect]); + } + }, [disconnect]) return { state, tools, error, log, + authUrl, callTool, retry, - disconnect - }; + disconnect, + authenticate, + } } /** @@ -569,102 +744,103 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { export async function onMcpAuthorization(query: Record) { try { // Extract the authorization code and state - const code = query.code; - const state = query.state; + const code = query.code + const state = query.state if (!code) { - throw new Error("No authorization code received"); + throw new Error('No authorization code received') } if (!state) { - throw new Error("No state parameter received"); + throw new Error('No state parameter received') } // Find the matching auth state in localStorage - const storageKeys = Object.keys(localStorage).filter(key => - key.includes('_auth_state') && localStorage.getItem(key) === state - ); + const storageKeys = Object.keys(localStorage).filter((key) => key.includes('_auth_state') && localStorage.getItem(key) === state) if (storageKeys.length === 0) { - throw new Error("No matching auth state found in storage"); + throw new Error('No matching auth state found in storage') } - const storageKey = storageKeys[0]; - const keyParts = storageKey.split('_'); - const serverUrlHash = keyParts[1]; - const storageKeyPrefix = keyParts[0]; + const storageKey = storageKeys[0] + const keyParts = storageKey.split('_') + const serverUrlHash = keyParts[1] + const storageKeyPrefix = keyParts[0] // Find all related auth data with the same prefix and server hash - const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info`; - const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier`; + const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info` + const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier` - const clientInfoStr = localStorage.getItem(clientInfoKey); - const codeVerifier = localStorage.getItem(codeVerifierKey); + const clientInfoStr = localStorage.getItem(clientInfoKey) + const codeVerifier = localStorage.getItem(codeVerifierKey) if (!clientInfoStr) { - throw new Error("No client information found in storage"); + throw new Error('No client information found in storage') } if (!codeVerifier) { - throw new Error("No code verifier found in storage"); + throw new Error('No code verifier found in storage') } // Parse client info - const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation; + const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation // Find the server URL from other keys in localStorage - const serverUrlKeys = Object.keys(localStorage).filter(key => - key.startsWith(`${storageKeyPrefix}_server_`) && key.includes(serverUrlHash) - ); + const serverUrlKeys = Object.keys(localStorage).filter( + (key) => key.startsWith(`${storageKeyPrefix}_server_`) && key.includes(serverUrlHash), + ) - let serverUrl: string; + let serverUrl: string if (serverUrlKeys.length > 0) { - serverUrl = localStorage.getItem(serverUrlKeys[0]) || ''; + serverUrl = localStorage.getItem(serverUrlKeys[0]) || '' } else { // If we can't find the server URL, try to construct it from the current URL // This is a fallback and may not always work - const currentUrl = new URL(window.location.href); - serverUrl = `${currentUrl.protocol}//${currentUrl.host}`; + const currentUrl = new URL(window.location.href) + serverUrl = `${currentUrl.protocol}//${currentUrl.host}` } if (!serverUrl) { - throw new Error("Could not determine server URL"); + throw new Error('Could not determine server URL') } // Exchange the code for tokens - const metadata = await discoverOAuthMetadata(serverUrl); + const metadata = await discoverOAuthMetadata(serverUrl) const tokens = await exchangeAuthorization(serverUrl, { metadata, clientInformation: clientInfo, authorizationCode: code, codeVerifier, - }); + }) // Save the tokens - const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens`; - localStorage.setItem(tokensKey, JSON.stringify(tokens)); + const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens` + localStorage.setItem(tokensKey, JSON.stringify(tokens)) // Post message back to the parent window if (window.opener && !window.opener.closed) { - window.opener.postMessage({ - type: 'mcp_auth_callback', - code - }, window.location.origin); + window.opener.postMessage( + { + type: 'mcp_auth_callback', + code, + }, + window.location.origin, + ) // Close the popup - window.close(); + window.close() } else { // If no parent window, we're in a redirect flow // Redirect back to the main page - window.location.href = '/'; + window.location.href = '/' } - return { success: true }; + return { success: true } } catch (error) { - console.error('Error in MCP authorization:', error); + console.error('Error in MCP authorization:', error) // Create a readable error message for display - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = error instanceof Error ? error.message : String(error) // If the popup is still open, show the error const errorHtml = ` @@ -684,10 +860,10 @@ export async function onMcpAuthorization(query: Record) {

You can close this window and try again.

- `; + ` - document.body.innerHTML = errorHtml; + document.body.innerHTML = errorHtml - return { success: false, error: errorMessage }; + return { success: false, error: errorMessage } } -} \ No newline at end of file +} From 5175099e237c3f337e49ba4773815d32b4c6681d Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 24 Mar 2025 15:21:18 +1100 Subject: [PATCH 4/5] Successfully tested end-to-end in a browser! --- README.md | 4 +- package.json | 2 +- pnpm-lock.yaml | 24 ++--- src/react/index.ts | 242 +++++++++++++++++++-------------------------- 4 files changed, 117 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index 7dab6a6..99b1e8a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # `mcp-remote` -**EXPERIMENTAL PROOF OF CONCEPT** - Connect an MCP Client that only supports local (stdio) servers to a Remote MCP Server, with auth support: +**Note: this is a working proof-of-concept** but should be considered **experimental** + E.g: Claude Desktop or Windsurf ```json diff --git a/package.json b/package.json index 479bedd..580b887 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,11 @@ "build": "tsup" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.7.0", "express": "^4.21.2", "open": "^10.1.0" }, "devDependencies": { + "@modelcontextprotocol/sdk": "^1.7.0", "@types/express": "^5.0.0", "@types/node": "^22.13.10", "@types/react": "^19.0.12", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca214d2..bf2b644 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@modelcontextprotocol/sdk': - specifier: ^1.7.0 - version: 1.7.0 express: specifier: ^4.21.2 version: 4.21.2 @@ -18,6 +15,9 @@ importers: specifier: ^10.1.0 version: 10.1.0 devDependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.7.0 + version: 1.7.0 '@types/express': specifier: ^5.0.0 version: 5.0.0 @@ -763,8 +763,8 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} - mime-db@1.53.0: - resolution: {integrity: sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg==} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} mime-types@2.1.35: @@ -1127,8 +1127,8 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - zod-to-json-schema@3.24.4: - resolution: {integrity: sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig==} + zod-to-json-schema@3.24.5: + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} peerDependencies: zod: ^3.24.1 @@ -1248,7 +1248,7 @@ snapshots: pkce-challenge: 4.1.0 raw-body: 3.0.0 zod: 3.24.2 - zod-to-json-schema: 3.24.4(zod@3.24.2) + zod-to-json-schema: 3.24.5(zod@3.24.2) transitivePeerDependencies: - supports-color @@ -1811,7 +1811,7 @@ snapshots: mime-db@1.52.0: {} - mime-db@1.53.0: {} + mime-db@1.54.0: {} mime-types@2.1.35: dependencies: @@ -1819,7 +1819,7 @@ snapshots: mime-types@3.0.0: dependencies: - mime-db: 1.53.0 + mime-db: 1.54.0 mime@1.6.0: {} @@ -1991,7 +1991,7 @@ snapshots: send@1.1.0: dependencies: - debug: 4.3.6 + debug: 4.4.0 destroy: 1.2.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -2203,7 +2203,7 @@ snapshots: wrappy@1.0.2: {} - zod-to-json-schema@3.24.4(zod@3.24.2): + zod-to-json-schema@3.24.5(zod@3.24.2): dependencies: zod: 3.24.2 diff --git a/src/react/index.ts b/src/react/index.ts index a933982..1dc3d25 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,9 +1,9 @@ -import { Tool, OAuthMetadata, JSONRPCMessage, OAuthClientInformation, OAuthTokens } from '@modelcontextprotocol/sdk/types.js' -import { useCallback, useEffect, useState, useRef } from 'react' +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 { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' -import { discoverOAuthMetadata, startAuthorization, exchangeAuthorization } from '@modelcontextprotocol/sdk/client/auth.js' +import { discoverOAuthMetadata, exchangeAuthorization, startAuthorization } from '@modelcontextprotocol/sdk/client/auth.js' +import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' export type UseMcpOptions = { /** The /sse URL of your remote MCP server */ @@ -27,8 +27,6 @@ export type UseMcpOptions = { autoRetry?: boolean | number /** Auto reconnect if connection is lost, with delay in ms (default: 3000) */ autoReconnect?: boolean | number - /** OAuth authentication mode (default: 'popup-with-redirect-fallback') */ - authMode?: 'popup-only' | 'redirect-only' | 'popup-with-redirect-fallback' /** Popup window features (dimensions and behavior) for OAuth */ popupFeatures?: string } @@ -39,15 +37,20 @@ export type UseMcpResult = { * The current state of the MCP connection. This will be one of: * - 'discovering': Finding out whether there is in fact a server at that URL, and what its capabilities are * - 'authenticating': The server has indicated we must authenticate, so we can't proceed until that's complete - * - 'popup-blocked': The auth popup was blocked by the browser, manual authentication is needed * - 'connecting': The connection to the MCP server is being established. This happens before we know whether we need to authenticate or not, and then again once we have credentials * - 'loading': We're connected to the MCP server, and now we're loading its resources/prompts/tools * - 'ready': The MCP server is connected and ready to be used * - 'failed': The connection to the MCP server failed * */ - state: 'discovering' | 'authenticating' | 'popup-blocked' | 'connecting' | 'loading' | 'ready' | 'failed' + state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed' /** If the state is 'failed', this will be the error message */ error?: string + /** + * If authorization was blocked, this will contain the URL to authorize manually + * The app can render this as a link with target="_blank" so the user can complete + * authorization without leaving the app + */ + authUrl?: string /** All internal log messages */ log: { level: 'debug' | 'info' | 'warn' | 'error'; message: string }[] /** Call a tool on the MCP server */ @@ -57,12 +60,17 @@ export type UseMcpResult = { /** Manually disconnect from the MCP server */ disconnect: () => void /** - * Manually trigger authentication (useful when popup is blocked) + * Manually trigger authentication * @returns Auth URL that can be used to manually open a new window */ authenticate: () => Promise - /** Authentication URL to manually open if popup is blocked */ - authUrl?: string +} + +type StoredState = { + authorizationUrl: string + metadata: OAuthMetadata + serverUrlHash: string + expiry: number } /** @@ -84,7 +92,7 @@ class BrowserOAuthClientProvider { callbackUrl?: string } = {}, ) { - this.storageKeyPrefix = options.storageKeyPrefix || 'mcp_auth' + this.storageKeyPrefix = options.storageKeyPrefix || 'mcp:auth' this.serverUrlHash = this.hashString(serverUrl) this.clientName = options.clientName || 'MCP Browser Client' this.clientUri = options.clientUri || window.location.origin @@ -157,89 +165,63 @@ class BrowserOAuthClientProvider { async redirectToAuthorization( authorizationUrl: URL, + metadata: OAuthMetadata, options?: { - mode?: 'popup-only' | 'redirect-only' | 'popup-with-redirect-fallback' popupFeatures?: string }, ): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> { // Store the auth state for the popup flow - const stateKey = this.getKey('auth_state') const state = Math.random().toString(36).substring(2) - localStorage.setItem(stateKey, state) + const stateKey = `${this.storageKeyPrefix}:state_${state}` + localStorage.setItem( + stateKey, + JSON.stringify({ + authorizationUrl: authorizationUrl.toString(), + metadata, + serverUrlHash: this.serverUrlHash, + expiry: +new Date() + 1000 * 60 * 5 /* 5 minutes */, + } as StoredState), + ) authorizationUrl.searchParams.set('state', state) const authUrl = authorizationUrl.toString() - const mode = options?.mode || 'popup-only' const popupFeatures = options?.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes' // Store the auth URL in case we need it for manual authentication localStorage.setItem(this.getKey('auth_url'), authUrl) - console.log({ mode }) - if (mode === 'redirect-only') { - // Redirect in the current window - window.location.href = authUrl - return { success: true, url: authUrl } - } + try { + // Open the authorization URL in a popup window + const popup = window.open(authUrl, 'mcp_auth', popupFeatures) - if (mode === 'popup-only' || mode === 'popup-with-redirect-fallback') { - try { - // Open the authorization URL in a popup window - const popup = window.open(authUrl, 'mcp_auth', popupFeatures) - console.log({ popup }) - - // Check if popup was blocked or closed immediately - if (!popup || popup.closed || popup.closed === undefined) { - if (mode === 'popup-with-redirect-fallback') { - // Fall back to redirect - console.warn('Popup blocked. Redirecting in the same window...') - window.location.href = authUrl - return { success: true, popupBlocked: true, url: authUrl } - } else { - // Popup-only mode, return error - console.warn('Popup blocked and redirect fallback disabled') - return { success: false, popupBlocked: true, url: authUrl } - } - } - - // Try to access the popup to confirm it's not blocked - try { - // Just accessing any property will throw if popup is blocked - const popupLocation = popup.location - // If we can read location.href, the popup is definitely working - if (popupLocation.href) { - // Successfully opened popup - return { success: true, url: authUrl } - } - } catch (e) { - // Access to the popup was denied, indicating it's blocked - if (mode === 'popup-with-redirect-fallback') { - console.warn('Popup blocked (security exception). Redirecting in the same window...') - window.location.href = authUrl - return { success: true, popupBlocked: true, url: authUrl } - } else { - console.warn('Popup blocked (security exception) and redirect fallback disabled') - return { success: false, popupBlocked: true, url: authUrl } - } - } - - // If we got here, popup is working - return { success: true, url: authUrl } - } catch (e) { - // Error opening popup - if (mode === 'popup-with-redirect-fallback') { - console.warn('Error opening popup:', e, 'Falling back to redirect') - window.location.href = authUrl - return { success: true, popupBlocked: true, url: authUrl } - } else { - console.warn('Error opening popup:', e, 'and redirect fallback disabled') - return { success: false, popupBlocked: true, url: authUrl } - } + // Check if popup was blocked or closed immediately + if (!popup || popup.closed || popup.closed === undefined) { + console.warn('Popup blocked. Returning error.') + return { success: false, popupBlocked: true, url: authUrl } } - } - // This shouldn't happen given the enum constraint, but TypeScript doesn't know that - throw new Error(`Invalid auth mode: ${mode}`) + // Try to access the popup to confirm it's not blocked + try { + // Just accessing any property will throw if popup is blocked + const popupLocation = popup.location + // If we can read location.href, the popup is definitely working + if (popupLocation.href) { + // Successfully opened popup + return { success: true, url: authUrl } + } + } catch (e) { + // Access to the popup was denied, indicating it's blocked + console.warn('Popup blocked (security exception).') + return { success: false, popupBlocked: true, url: authUrl } + } + + // If we got here, popup is working + return { success: true, url: authUrl } + } catch (e) { + // Error opening popup + console.warn('Error opening popup:', e) + return { success: false, popupBlocked: true, url: authUrl } + } } async saveCodeVerifier(codeVerifier: string): Promise { @@ -285,7 +267,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { clientName = 'MCP React Client', clientUri = window.location.origin, callbackUrl = new URL('/oauth/callback', window.location.origin).toString(), - storageKeyPrefix = 'mcp_auth', + storageKeyPrefix = 'mcp:auth', clientConfig = { name: 'mcp-react-client', version: '0.1.0', @@ -293,14 +275,12 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { debug = false, autoRetry = false, autoReconnect = 3000, - authMode = 'popup-only', popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes', } = options // Add to log const addLog = useCallback( (level: 'debug' | 'info' | 'warn' | 'error', message: string) => { - console.log(message) if (level === 'debug' && !debug) return setLog((prevLog) => [...prevLog, { level, message }]) }, @@ -315,10 +295,14 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { } try { - const result = await clientRef.current.request({ - method: 'tools/call', - params: { name, arguments: args }, - }) + console.log('CALLING TOOL') + const result = await clientRef.current.request( + { + method: 'tools/call', + params: { name, arguments: args }, + }, + CallToolResultSchema, + ) return result } catch (err) { addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`) @@ -444,7 +428,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { // Connect transport try { addLog('info', 'Starting transport...') - await transportRef.current.start() + // await transportRef.current.start() } catch (err) { addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`) @@ -596,7 +580,11 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { window.removeEventListener('message', messageHandler) clearTimeout(timeoutId) - resolve(event.data.code) + + // TODO: not this, obviously + // reload window, we should find the token in local storage + window.location.reload() + // resolve(event.data.code) } } @@ -605,33 +593,24 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { // Redirect to authorization addLog('info', 'Opening authorization window...') - const redirectResult = await authProviderRef.current.redirectToAuthorization(authUrlRef.current, { - mode: authMode, - popupFeatures, - }) + const redirectResult = await authProviderRef.current.redirectToAuthorization(authUrlRef.current, metadataRef.current, { popupFeatures }) if (!redirectResult.success) { - // Popup was blocked and we're in popup-only mode - setState('popup-blocked') - setError('Authentication popup was blocked by the browser') + // Popup was blocked + setState('failed') + setError('Authentication popup was blocked by the browser. Please click the link to authenticate in a new window.') + setAuthUrl(redirectResult.url) addLog('warn', 'Authentication popup was blocked. User needs to manually authorize.') throw new Error('Authentication popup blocked') } - if (redirectResult.popupBlocked && authMode === 'popup-with-redirect-fallback') { - // The popup was blocked but we've fallen back to redirect - // No need to wait for the auth promise since we're redirecting - addLog('info', 'Popup blocked, falling back to redirect...') - return 'redirect-in-progress' - } - // Wait for auth to complete addLog('info', 'Waiting for authorization...') const code = await authPromise addLog('info', 'Authorization code received') return code - }, [url, addLog, authMode, popupFeatures, startAuthFlow]) + }, [url, addLog, popupFeatures, startAuthFlow]) // Handle auth completion - this is called when we receive a message from the popup const handleAuthCompletion = useCallback( @@ -649,11 +628,6 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { authUrlRef.current = undefined setAuthUrl(undefined) - // Reset popup blocked state if we were in that state - if (state === 'popup-blocked') { - setState('authenticating') - } - // Reconnect with the new auth token await disconnect() connect() @@ -663,7 +637,7 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`) } }, - [addLog, disconnect, connect, state], + [addLog, disconnect, connect], ) // Retry connection @@ -741,7 +715,14 @@ export function useMcp(options: UseMcpOptions): UseMcpResult { * Once it's updated LocalStorage with the auth token, it will post a message back to the original * window to inform any running `useMcp` hooks that the auth flow is complete. */ -export async function onMcpAuthorization(query: Record) { +export async function onMcpAuthorization( + query: Record, + { + storageKeyPrefix = 'mcp:auth', + }: { + storageKeyPrefix?: string + } = {}, +) { try { // Extract the authorization code and state const code = query.code @@ -756,20 +737,23 @@ export async function onMcpAuthorization(query: Record) { } // Find the matching auth state in localStorage - const storageKeys = Object.keys(localStorage).filter((key) => key.includes('_auth_state') && localStorage.getItem(key) === state) + // const storageKeys = Object.keys(localStorage).filter((key) => key.includes('_auth_state') && localStorage.getItem(key) === state) - if (storageKeys.length === 0) { + const stateKey = `${storageKeyPrefix}:state_${state}` + const storedState = localStorage.getItem(stateKey) + console.log({ stateKey, storedState }) + if (!storedState) { throw new Error('No matching auth state found in storage') } - - const storageKey = storageKeys[0] - const keyParts = storageKey.split('_') - const serverUrlHash = keyParts[1] - const storageKeyPrefix = keyParts[0] + const { authorizationUrl, serverUrlHash, metadata, expiry } = JSON.parse(storedState) + if (expiry < Date.now()) { + throw new Error('Auth state has expired') + } // Find all related auth data with the same prefix and server hash const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info` const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier` + console.log({ authorizationUrl, clientInfoKey, codeVerifierKey }) const clientInfoStr = localStorage.getItem(clientInfoKey) const codeVerifier = localStorage.getItem(codeVerifierKey) @@ -785,29 +769,7 @@ export async function onMcpAuthorization(query: Record) { // Parse client info const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation - // Find the server URL from other keys in localStorage - const serverUrlKeys = Object.keys(localStorage).filter( - (key) => key.startsWith(`${storageKeyPrefix}_server_`) && key.includes(serverUrlHash), - ) - - let serverUrl: string - if (serverUrlKeys.length > 0) { - serverUrl = localStorage.getItem(serverUrlKeys[0]) || '' - } else { - // If we can't find the server URL, try to construct it from the current URL - // This is a fallback and may not always work - const currentUrl = new URL(window.location.href) - serverUrl = `${currentUrl.protocol}//${currentUrl.host}` - } - - if (!serverUrl) { - throw new Error('Could not determine server URL') - } - - // Exchange the code for tokens - const metadata = await discoverOAuthMetadata(serverUrl) - - const tokens = await exchangeAuthorization(serverUrl, { + const tokens = await exchangeAuthorization(new URL('/', authorizationUrl), { metadata, clientInformation: clientInfo, authorizationCode: code, @@ -816,6 +778,7 @@ export async function onMcpAuthorization(query: Record) { // Save the tokens const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens` + console.log({ tokensKey, tokens }) localStorage.setItem(tokensKey, JSON.stringify(tokens)) // Post message back to the parent window @@ -823,7 +786,6 @@ export async function onMcpAuthorization(query: Record) { window.opener.postMessage( { type: 'mcp_auth_callback', - code, }, window.location.origin, ) From 740317123d69cfcc25e2fdddc91f2a480f18bec4 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 24 Mar 2025 16:02:19 +1100 Subject: [PATCH 5/5] 0.0.5-0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 580b887..d09e160 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-remote", - "version": "0.0.4", + "version": "0.0.5-0", "type": "module", "bin": "dist/cli/proxy.js", "files": [