From 5175099e237c3f337e49ba4773815d32b4c6681d Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 24 Mar 2025 15:21:18 +1100 Subject: [PATCH] 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, )