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 bd33bef..d09e160 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,34 @@ { "name": "mcp-remote", - "version": "0.0.4", + "version": "0.0.5-0", "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" }, "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", "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..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,15 +15,24 @@ 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 '@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 + 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) @@ -344,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==} @@ -470,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: @@ -751,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: @@ -903,6 +915,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'} @@ -1111,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 @@ -1232,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 @@ -1333,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 @@ -1469,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 @@ -1789,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: @@ -1797,7 +1819,7 @@ snapshots: mime-types@3.0.0: dependencies: - mime-db: 1.53.0 + mime-db: 1.54.0 mime@1.6.0: {} @@ -1904,6 +1926,8 @@ snapshots: iconv-lite: 0.6.3 unpipe: 1.0.0 + react@19.0.0: {} + readdirp@4.1.2: {} resolve-from@5.0.0: {} @@ -1967,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 @@ -2179,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/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..1dc3d25 --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,831 @@ +import { CallToolResultSchema, JSONRPCMessage, ListToolsResultSchema, Tool } from '@modelcontextprotocol/sdk/types.js' +import { useCallback, useEffect, useRef, useState } from 'react' +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { discoverOAuthMetadata, exchangeAuthorization, startAuthorization } from '@modelcontextprotocol/sdk/client/auth.js' +import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' + +export type UseMcpOptions = { + /** The /sse URL of your remote MCP server */ + url: string + /** OAuth client name for registration */ + clientName?: string + /** OAuth client URI for registration */ + clientUri?: string + /** Custom callback URL for OAuth redirect (defaults to /oauth/callback on the current origin) */ + callbackUrl?: string + /** Storage key prefix for OAuth data (defaults to "mcp_auth") */ + storageKeyPrefix?: string + /** Custom configuration for the MCP client */ + clientConfig?: { + name?: string + version?: string + } + /** Whether to enable debug logging */ + debug?: boolean + /** Auto retry connection if it fails, with delay in ms (default: false) */ + autoRetry?: boolean | number + /** Auto reconnect if connection is lost, with delay in ms (default: 3000) */ + autoReconnect?: boolean | number + /** Popup window features (dimensions and behavior) for OAuth */ + popupFeatures?: string +} + +export type UseMcpResult = { + tools: Tool[] + /** + * The current state of the MCP connection. This will be one of: + * - 'discovering': Finding out whether there is in fact a server at that URL, and what its capabilities are + * - 'authenticating': The server has indicated we must authenticate, so we can't proceed until that's complete + * - 'connecting': The connection to the MCP server is being established. This happens before we know whether we need to authenticate or not, and then again once we have credentials + * - 'loading': We're connected to the MCP server, and now we're loading its resources/prompts/tools + * - 'ready': The MCP server is connected and ready to be used + * - 'failed': The connection to the MCP server failed + * */ + state: 'discovering' | 'authenticating' | 'connecting' | 'loading' | 'ready' | 'failed' + /** If the state is 'failed', this will be the error message */ + error?: string + /** + * If authorization was blocked, this will contain the URL to authorize manually + * The app can render this as a link with target="_blank" so the user can complete + * authorization without leaving the app + */ + authUrl?: string + /** All internal log messages */ + log: { level: 'debug' | 'info' | 'warn' | 'error'; message: string }[] + /** Call a tool on the MCP server */ + callTool: (name: string, args?: Record) => Promise + /** Manually retry connection if it's in a failed state */ + retry: () => void + /** Manually disconnect from the MCP server */ + disconnect: () => void + /** + * Manually trigger authentication + * @returns Auth URL that can be used to manually open a new window + */ + authenticate: () => Promise +} + +type StoredState = { + authorizationUrl: string + metadata: OAuthMetadata + serverUrlHash: string + expiry: number +} + +/** + * 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, + metadata: OAuthMetadata, + options?: { + popupFeatures?: string + }, + ): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> { + // Store the auth state for the popup flow + const state = Math.random().toString(36).substring(2) + const stateKey = `${this.storageKeyPrefix}:state_${state}` + localStorage.setItem( + stateKey, + JSON.stringify({ + authorizationUrl: authorizationUrl.toString(), + metadata, + serverUrlHash: this.serverUrlHash, + expiry: +new Date() + 1000 * 60 * 5 /* 5 minutes */, + } as StoredState), + ) + authorizationUrl.searchParams.set('state', state) + + const authUrl = authorizationUrl.toString() + const popupFeatures = options?.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes' + + // Store the auth URL in case we need it for manual authentication + localStorage.setItem(this.getKey('auth_url'), authUrl) + + try { + // Open the authorization URL in a popup window + const popup = window.open(authUrl, 'mcp_auth', popupFeatures) + + // Check if popup was blocked or closed immediately + if (!popup || popup.closed || popup.closed === undefined) { + console.warn('Popup blocked. Returning error.') + return { success: false, popupBlocked: true, url: authUrl } + } + + // Try to access the popup to confirm it's not blocked + try { + // Just accessing any property will throw if popup is blocked + const popupLocation = popup.location + // If we can read location.href, the popup is definitely working + if (popupLocation.href) { + // Successfully opened popup + return { success: true, url: authUrl } + } + } catch (e) { + // Access to the popup was denied, indicating it's blocked + console.warn('Popup blocked (security exception).') + return { success: false, popupBlocked: true, url: authUrl } + } + + // If we got here, popup is working + return { success: true, url: authUrl } + } catch (e) { + // Error opening popup + console.warn('Error opening popup:', e) + return { success: false, popupBlocked: true, url: authUrl } + } + } + + async saveCodeVerifier(codeVerifier: string): Promise { + 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`). + */ +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 [authUrl, setAuthUrl] = useState(undefined) + + 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', + clientUri = window.location.origin, + callbackUrl = new URL('/oauth/callback', window.location.origin).toString(), + storageKeyPrefix = 'mcp:auth', + clientConfig = { + name: 'mcp-react-client', + version: '0.1.0', + }, + debug = false, + autoRetry = false, + autoReconnect = 3000, + popupFeatures = 'width=600,height=700,resizable=yes,scrollbars=yes', + } = options + + // Add to log + const addLog = useCallback( + (level: 'debug' | 'info' | 'warn' | 'error', message: string) => { + if (level === 'debug' && !debug) return + setLog((prevLog) => [...prevLog, { level, message }]) + }, + [debug], + ) + + // Call a tool on the MCP server + const callTool = useCallback( + async (name: string, args?: Record) => { + if (!clientRef.current || state !== 'ready') { + throw new Error('MCP client not ready') + } + + try { + console.log('CALLING TOOL') + const result = await clientRef.current.request( + { + method: 'tools/call', + params: { name, arguments: args }, + }, + CallToolResultSchema, + ) + return result + } catch (err) { + addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`) + throw err + } + }, + [state, addLog], + ) + + // Disconnect from the MCP server + const disconnect = useCallback(async () => { + if (clientRef.current) { + try { + await clientRef.current.close() + } catch (err) { + addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`) + } + clientRef.current = null + } + + if (transportRef.current) { + try { + await transportRef.current.close() + } catch (err) { + addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`) + } + transportRef.current = null + } + + connectingRef.current = false + setState('discovering') + setTools([]) + setError(undefined) + }, [addLog]) + + // 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, + ]) + + // 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') + } + + addLog('info', 'Starting authentication flow...') + + // Check if we have client info + let clientInfo = await authProviderRef.current.clientInformation() + + if (!clientInfo) { + // Register client dynamically + addLog('info', 'No client information found, registering...') + // Note: In a complete implementation, you'd register the client here + // This would be done server-side in a real application + throw new Error('Dynamic client registration not implemented in this example') + } + + // Start authorization flow + addLog('info', 'Preparing authorization...') + const { authorizationUrl, codeVerifier } = await startAuthorization(url, { + metadata: metadataRef.current, + clientInformation: clientInfo, + redirectUrl: authProviderRef.current.redirectUrl, + }) + + // Save code verifier and auth URL for later use + await authProviderRef.current.saveCodeVerifier(codeVerifier) + codeVerifierRef.current = codeVerifier + authUrlRef.current = authorizationUrl + setAuthUrl(authorizationUrl.toString()) + + return authorizationUrl + }, [url, addLog]) + + // Handle authentication flow + 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 messageHandler = (event: MessageEvent) => { + // Verify origin for security + if (event.origin !== window.location.origin) return + + if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { + window.removeEventListener('message', messageHandler) + clearTimeout(timeoutId) + + // TODO: not this, obviously + // reload window, we should find the token in local storage + window.location.reload() + // resolve(event.data.code) + } + } + + window.addEventListener('message', messageHandler) + }) + + // Redirect to authorization + addLog('info', 'Opening authorization window...') + const redirectResult = await authProviderRef.current.redirectToAuthorization(authUrlRef.current, metadataRef.current, { popupFeatures }) + + if (!redirectResult.success) { + // Popup was blocked + setState('failed') + setError('Authentication popup was blocked by the browser. Please click the link to authenticate in a new window.') + setAuthUrl(redirectResult.url) + addLog('warn', 'Authentication popup was blocked. User needs to manually authorize.') + throw new Error('Authentication popup blocked') + } + + // Wait for auth to complete + addLog('info', 'Waiting for authorization...') + const code = await authPromise + addLog('info', 'Authorization code received') + + return code + }, [url, addLog, popupFeatures, startAuthFlow]) + + // Handle auth completion - this is called when we receive a message from the popup + const handleAuthCompletion = useCallback( + async (code: string) => { + if (!authProviderRef.current || !transportRef.current) { + throw new Error('Authentication context not available') + } + + try { + addLog('info', 'Finishing authorization...') + await transportRef.current.finishAuth(code) + addLog('info', 'Authorization completed') + + // Reset auth URL state + authUrlRef.current = undefined + setAuthUrl(undefined) + + // Reconnect with the new auth token + await disconnect() + connect() + } catch (err) { + addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`) + setState('failed') + setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } + }, + [addLog, disconnect, connect], + ) + + // Retry connection + const retry = useCallback(() => { + if (state === 'failed') { + disconnect().then(() => connect()) + } + }, [state, disconnect, connect]) + + // Set up message listener for auth callback + useEffect(() => { + const messageHandler = (event: MessageEvent) => { + // Verify origin for security + if (event.origin !== window.location.origin) return + + if (event.data && event.data.type === 'mcp_auth_callback' && event.data.code) { + handleAuthCompletion(event.data.code).catch((err) => { + addLog('error', `Auth callback error: ${err.message}`) + }) + } + } + + window.addEventListener('message', messageHandler) + return () => { + window.removeEventListener('message', messageHandler) + } + }, [handleAuthCompletion, addLog]) + + // Initial connection and auto-retry + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false + connect() + } else if (state === 'failed' && autoRetry) { + const delay = typeof autoRetry === 'number' ? autoRetry : 5000 + const timeoutId = setTimeout(() => { + addLog('info', 'Auto-retrying connection...') + disconnect().then(() => connect()) + }, delay) + + return () => { + clearTimeout(timeoutId) + } + } + }, [state, autoRetry, connect, disconnect, addLog]) + + // Clean up on unmount + useEffect(() => { + return () => { + if (clientRef.current || transportRef.current) { + disconnect() + } + } + }, [disconnect]) + + return { + state, + tools, + error, + log, + authUrl, + callTool, + retry, + disconnect, + authenticate, + } +} + +/** + * 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, + { + storageKeyPrefix = 'mcp:auth', + }: { + storageKeyPrefix?: string + } = {}, +) { + try { + // Extract the authorization code and state + const code = query.code + const state = query.state + + if (!code) { + throw new Error('No authorization code received') + } + + if (!state) { + throw new Error('No state parameter received') + } + + // Find the matching auth state in localStorage + // const storageKeys = Object.keys(localStorage).filter((key) => key.includes('_auth_state') && localStorage.getItem(key) === state) + + const stateKey = `${storageKeyPrefix}:state_${state}` + const storedState = localStorage.getItem(stateKey) + console.log({ stateKey, storedState }) + if (!storedState) { + throw new Error('No matching auth state found in storage') + } + const { authorizationUrl, serverUrlHash, metadata, expiry } = JSON.parse(storedState) + if (expiry < Date.now()) { + throw new Error('Auth state has expired') + } + + // Find all related auth data with the same prefix and server hash + const clientInfoKey = `${storageKeyPrefix}_${serverUrlHash}_client_info` + const codeVerifierKey = `${storageKeyPrefix}_${serverUrlHash}_code_verifier` + console.log({ authorizationUrl, clientInfoKey, codeVerifierKey }) + + const clientInfoStr = localStorage.getItem(clientInfoKey) + const codeVerifier = localStorage.getItem(codeVerifierKey) + + if (!clientInfoStr) { + throw new Error('No client information found in storage') + } + + if (!codeVerifier) { + throw new Error('No code verifier found in storage') + } + + // Parse client info + const clientInfo = JSON.parse(clientInfoStr) as OAuthClientInformation + + const tokens = await exchangeAuthorization(new URL('/', authorizationUrl), { + metadata, + clientInformation: clientInfo, + authorizationCode: code, + codeVerifier, + }) + + // Save the tokens + const tokensKey = `${storageKeyPrefix}_${serverUrlHash}_tokens` + console.log({ tokensKey, tokens }) + localStorage.setItem(tokensKey, JSON.stringify(tokens)) + + // Post message back to the parent window + if (window.opener && !window.opener.closed) { + window.opener.postMessage( + { + type: 'mcp_auth_callback', + }, + window.location.origin, + ) + // Close the popup + window.close() + } else { + // If no parent window, we're in a redirect flow + // Redirect back to the main page + window.location.href = '/' + } + + return { success: true } + } catch (error) { + console.error('Error in MCP authorization:', error) + + // Create a readable error message for display + const errorMessage = error instanceof Error ? error.message : String(error) + + // If the popup is still open, show the error + const errorHtml = ` + + + Authentication Error + + + +

Authentication Error

+
+

${errorMessage}

+
+

You can close this window and try again.

+ + + ` + + document.body.innerHTML = errorHtml + + return { success: false, error: errorMessage } + } +} 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 c1849c0..a203cbb 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,10 +1,10 @@ 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, outDir: 'dist', - // external: ['typescript'], + external: ['react'], })