diff --git a/README.md b/README.md index 79f220a..bcb15bd 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,7 @@ That's where `mcp-remote` comes in. As soon as your chosen MCP client supports r ## Usage -### Claude Desktop - -[Official Docs](https://modelcontextprotocol.io/quickstart/user) - -In order to add an MCP server to Claude Desktop you need to edit the configuration file located at: - -macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - -Windows: `%APPDATA%\Claude\claude_desktop_config.json` - -If it does not exist yet, [you may need to enable it under Settings > Developer](https://modelcontextprotocol.io/quickstart/user#2-add-the-filesystem-mcp-server). +All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the following config format: ```json { @@ -34,7 +24,6 @@ If it does not exist yet, [you may need to enable it under Settings > Developer] "remote-example": { "command": "npx", "args": [ - "-y", "mcp-remote", "https://remote.mcp.server/sse" ] @@ -43,51 +32,72 @@ If it does not exist yet, [you may need to enable it under Settings > Developer] } ``` +### Flags + +* If `npx` is producing errors, consider adding `-y` as the first argument to auto-accept the installation of the `mcp-remote` package. + +```json + "command": "npx", + "args": [ + "-y" + "mcp-remote", + "https://remote.mcp.server/sse" + ] +``` + +* To force `npx` to always check for an updated version of `mcp-remote`, add the `@latest` flag: + +```json + "args": [ + "mcp-remote@latest", + "https://remote.mcp.server/sse" + ] +``` + +* To force `mcp-remote` to ignore any existing access tokens and begin the authorization flow anew, pass `--clean`. + +```json + "args": [ + "mcp-remote", + "https://remote.mcp.server/sse", + "--clean" + ] +``` + +* To change which port `mcp-remote` listens for an OAuth redirect (by default `3334`), add an additional argument after the server URL. Note that whatever port you specify, if it is unavailable an open port will be chosen at random. + +```json + "args": [ + "mcp-remote", + "https://remote.mcp.server/sse", + "9696" + ] +``` + +### Claude Desktop + +[Official Docs](https://modelcontextprotocol.io/quickstart/user) + +In order to add an MCP server to Claude Desktop you need to edit the configuration file located at: + +* macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +* Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +If it does not exist yet, [you may need to enable it under Settings > Developer](https://modelcontextprotocol.io/quickstart/user#2-add-the-filesystem-mcp-server). + Restart Claude Desktop to pick up the changes in the configuration file. Upon restarting, you should see a hammer icon in the bottom right corner of the input box. ### Cursor -[Official Docs](https://docs.cursor.com/context/model-context-protocol) +[Official Docs](https://docs.cursor.com/context/model-context-protocol). The configuration file is located at `~/.cursor/mcp.json`. -Add the following configuration to `~/.cursor/mcp.json`: - -```json -{ - "mcpServers": { - "remote-example": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://remote.mcp.server/sse" - ] - } - } -} -``` +As of version `0.48.0`, Cursor supports unauthed SSE servers directly. If your MCP server is using the official MCP OAuth authorization protocol, you still need to add a **"command"** server and call `mcp-remote`. ### Windsurf -[Official Docs](https://docs.codeium.com/windsurf/mcp) - -Add the following configuration to `~/.codeium/windsurf/mcp_config.json`: - -```json -{ - "mcpServers": { - "remote-example": { - "command": "npx", - "args": [ - "-y", - "mcp-remote", - "https://remote.mcp.server/sse" - ] - } - } -} -``` +[Official Docs](https://docs.codeium.com/windsurf/mcp). The configuration file is located at `~/.codeium/windsurf/mcp_config.json`. ## Building Remote MCP Servers @@ -106,7 +116,17 @@ For more information about testing these servers, see also: Know of more resources you'd like to share? Please add them to this Readme and send a PR! -## Debugging +## Troubleshooting + +### Clear your `~/.mcp-auth` directory + +`mcp-remote` stores all the credential information inside `~/.mcp-auth` (or wherever your `MCP_REMOTE_CONFIG_DIR` points to). If you're having persistent issues, try running: + +```sh +rm -rf ~/.mcp-auth +``` + +Then restarting your MCP client. ### Check your Node version @@ -144,20 +164,10 @@ this might look like: ### Check the logs -[Follow Claude Desktop logs in real-time](https://modelcontextprotocol.io/docs/tools/debugging#debugging-in-claude-desktop) - -MacOS / Linux: - -`tail -n 20 -F ~/Library/Logs/Claude/mcp*.log` - -For bash on WSL: - -`tail -n 20 -f "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log"` - -or Powershell: - -`Get-Content "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log" -Wait -Tail 20` - +* [Follow Claude Desktop logs in real-time](https://modelcontextprotocol.io/docs/tools/debugging#debugging-in-claude-desktop) +* MacOS / Linux:
`tail -n 20 -F ~/Library/Logs/Claude/mcp*.log` +* For bash on WSL:
`tail -n 20 -f "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log"` +* Powershell:
`Get-Content "C:\Users\YourUsername\AppData\Local\Claude\Logs\mcp.log" -Wait -Tail 20` ## Debugging @@ -169,3 +179,13 @@ Token exchange failed: HTTP 400 ``` You can run `rm -rf ~/.mcp-auth` to clear any locally stored state and tokens. + +### "Client" mode + +Run the following on the command line (not from an MCP server): + +```shell +npx -p mcp-remote@latest mcp-remote-client https://remote.mcp.server/sse +``` + +This will run through the entire authorization flow and attempt to list the tools & resources at the remote URL. Pair this with `--clean` or after running `rm -rf ~/.mcp-auth` to see if stale credentials are your problem, otherwise hopefully the issue will be more obvious in these logs than those in your MCP client. diff --git a/package.json b/package.json index e9f0be6..b1edf20 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,26 @@ { "name": "mcp-remote", - "version": "0.0.10", + "version": "0.0.15", + "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", + "keywords": [ + "mcp", + "stdio", + "sse", + "remote", + "oauth" + ], + "author": "Glen Maddern ", + "repository": "https://github.com/geelen/mcp-remote", "type": "module", - "bin": { - "mcp-remote": "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" - } + "main": "dist/index.js", + "bin": { + "mcp-remote": "dist/proxy.js", + "mcp-remote-client": "dist/client.js" }, "scripts": { "dev": "tsup --watch", @@ -39,9 +44,8 @@ }, "tsup": { "entry": [ - "src/cli/client.ts", - "src/cli/proxy.ts", - "src/react/index.ts" + "src/client.ts", + "src/proxy.ts" ], "format": [ "esm" diff --git a/src/cli/shared.ts b/src/cli/shared.ts deleted file mode 100644 index b146abd..0000000 --- a/src/cli/shared.ts +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Shared utilities for MCP OAuth clients and proxies. - * Contains common functionality for authentication, file storage, and proxying. - */ - -import express from 'express' -import open from 'open' -import fs from 'fs/promises' -import path from 'path' -import os from 'os' -import crypto from 'crypto' -import net from 'net' -import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' -import { - OAuthClientInformation, - OAuthClientInformationFull, - OAuthClientInformationSchema, - OAuthTokens, - OAuthTokensSchema, -} from '@modelcontextprotocol/sdk/shared/auth.js' -import { OAuthCallbackServerOptions, OAuthProviderOptions } from '../lib/types.js' - -/** - * Implements the OAuthClientProvider interface for Node.js environments. - * Handles OAuth flow and token storage for MCP clients. - */ -export class NodeOAuthClientProvider implements OAuthClientProvider { - private configDir: string - private serverUrlHash: string - private callbackPath: string - private clientName: string - private clientUri: string - - /** - * Creates a new NodeOAuthClientProvider - * @param options Configuration options for the provider - */ - constructor(readonly options: OAuthProviderOptions) { - this.serverUrlHash = crypto.createHash('md5').update(options.serverUrl).digest('hex') - this.configDir = options.configDir || path.join(os.homedir(), '.mcp-auth') - this.callbackPath = options.callbackPath || '/oauth/callback' - this.clientName = options.clientName || 'MCP CLI Client' - this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli' - } - - get redirectUrl(): string { - return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}` - } - - 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, - } - } - - /** - * Ensures the configuration directory exists - * @private - */ - private async ensureConfigDir() { - try { - await fs.mkdir(this.configDir, { recursive: true }) - } catch (error) { - console.error('Error creating config directory:', error) - throw error - } - } - - /** - * Reads a JSON file and parses it with the provided schema - * @param filename The name of the file to read - * @param schema The schema to validate against - * @returns The parsed file content or undefined if the file doesn't exist - * @private - */ - private async readFile(filename: string, schema: any): Promise { - try { - await this.ensureConfigDir() - const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`) - const content = await fs.readFile(filePath, 'utf-8') - return await schema.parseAsync(JSON.parse(content)) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return undefined - } - return undefined - } - } - - /** - * Writes a JSON object to a file - * @param filename The name of the file to write - * @param data The data to write - * @private - */ - private async writeFile(filename: string, data: any) { - try { - await this.ensureConfigDir() - const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`) - await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8') - } catch (error) { - console.error(`Error writing ${filename}:`, error) - throw error - } - } - - /** - * Writes a text string to a file - * @param filename The name of the file to write - * @param text The text to write - * @private - */ - private async writeTextFile(filename: string, text: string) { - try { - await this.ensureConfigDir() - const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`) - await fs.writeFile(filePath, text, 'utf-8') - } catch (error) { - console.error(`Error writing ${filename}:`, error) - throw error - } - } - - /** - * Reads text from a file - * @param filename The name of the file to read - * @returns The file content as a string - * @private - */ - private async readTextFile(filename: string): Promise { - try { - await this.ensureConfigDir() - const filePath = path.join(this.configDir, `${this.serverUrlHash}_${filename}`) - return await fs.readFile(filePath, 'utf-8') - } catch (error) { - throw new Error('No code verifier saved for session') - } - } - - /** - * Gets the client information if it exists - * @returns The client information or undefined - */ - async clientInformation(): Promise { - return this.readFile('client_info.json', OAuthClientInformationSchema) - } - - /** - * Saves client information - * @param clientInformation The client information to save - */ - async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise { - await this.writeFile('client_info.json', clientInformation) - } - - /** - * Gets the OAuth tokens if they exist - * @returns The OAuth tokens or undefined - */ - async tokens(): Promise { - return this.readFile('tokens.json', OAuthTokensSchema) - } - - /** - * Saves OAuth tokens - * @param tokens The tokens to save - */ - async saveTokens(tokens: OAuthTokens): Promise { - await this.writeFile('tokens.json', tokens) - } - - /** - * Redirects the user to the authorization URL - * @param authorizationUrl The URL to redirect to - */ - async redirectToAuthorization(authorizationUrl: URL): Promise { - console.error(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`) - try { - await open(authorizationUrl.toString()) - console.error('Browser opened automatically.') - } catch (error) { - console.error('Could not open browser automatically. Please copy and paste the URL above into your browser.') - } - } - - /** - * Saves the PKCE code verifier - * @param codeVerifier The code verifier to save - */ - async saveCodeVerifier(codeVerifier: string): Promise { - await this.writeTextFile('code_verifier.txt', codeVerifier) - } - - /** - * Gets the PKCE code verifier - * @returns The code verifier - */ - async codeVerifier(): Promise { - return await this.readTextFile('code_verifier.txt') - } -} - -/** - * Sets up an Express server to handle OAuth callbacks - * @param options The server options - * @returns An object with the server, authCode, and waitForAuthCode function - */ -export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { - let authCode: string | null = null - const app = express() - - app.get(options.path, (req, res) => { - const code = req.query.code as string | undefined - if (!code) { - res.status(400).send('Error: No authorization code received') - return - } - - authCode = code - res.send('Authorization successful! You may close this window and return to the CLI.') - - // Notify main flow that auth code is available - options.events.emit('auth-code-received', code) - }) - - const server = app.listen(options.port, () => { - console.error(`OAuth callback server running at http://127.0.0.1:${options.port}`) - }) - - /** - * Waits for the OAuth authorization code - * @returns A promise that resolves with the authorization code - */ - const waitForAuthCode = (): Promise => { - return new Promise((resolve) => { - if (authCode) { - resolve(authCode) - return - } - - options.events.once('auth-code-received', (code) => { - resolve(code) - }) - }) - } - - return { server, authCode, waitForAuthCode } -} - -/** - * Finds an available port on the local machine - * @param preferredPort Optional preferred port to try first - * @returns A promise that resolves to an available port number - */ -export async function findAvailablePort(preferredPort?: number): Promise { - return new Promise((resolve, reject) => { - const server = net.createServer() - - server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - // If preferred port is in use, get a random port - server.listen(0) - } else { - reject(err) - } - }) - - server.on('listening', () => { - const { port } = server.address() as net.AddressInfo - server.close(() => { - resolve(port) - }) - }) - - // Try preferred port first, or get a random port - server.listen(preferredPort || 0) - }) -} - -/** - * Parses command line arguments for MCP clients and proxies - * @param args Command line arguments - * @param defaultPort Default port for the callback server if specified port is unavailable - * @param usage Usage message to show on error - * @returns A promise that resolves to an object with parsed serverUrl and callbackPort - */ -export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) { - const serverUrl = args[0] - const specifiedPort = args[1] ? parseInt(args[1]) : undefined - - if (!serverUrl) { - console.error(usage) - process.exit(1) - } - - const url = new URL(serverUrl) - const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:' - - if (!(url.protocol == 'https:' || isLocalhost)) { - console.error(usage) - process.exit(1) - } - - // Use the specified port, or find an available one - const callbackPort = specifiedPort || (await findAvailablePort(defaultPort)) - - if (specifiedPort) { - console.error(`Using specified callback port: ${callbackPort}`) - } else { - console.error(`Using automatically selected callback port: ${callbackPort}`) - } - - return { serverUrl, callbackPort } -} - -/** - * Sets up signal handlers for graceful shutdown - * @param cleanup Cleanup function to run on shutdown - */ -export function setupSignalHandlers(cleanup: () => Promise) { - process.on('SIGINT', async () => { - console.error('\nShutting down...') - await cleanup() - process.exit(0) - }) - - // Keep the process alive - process.stdin.resume() -} diff --git a/src/cli/client.ts b/src/client.ts similarity index 53% rename from src/cli/client.ts rename to src/client.ts index f79e917..5b908c3 100644 --- a/src/cli/client.ts +++ b/src/client.ts @@ -4,7 +4,10 @@ * MCP Client with OAuth support * A command-line client that connects to an MCP server using SSE with OAuth authentication. * - * Run with: npx tsx client.ts https://example.remote/server [callback-port] + * Run with: npx tsx client.ts [--clean] https://example.remote/server [callback-port] + * + * Options: + * --clean: Deletes stored configuration before reading, ensuring a fresh session * * If callback-port is not specified, an available port will be automatically selected. */ @@ -14,32 +17,47 @@ 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.js' +import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' +import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, getServerUrlHash } from './lib/utils' +import { coordinateAuth } from './lib/coordination' /** * Main function to run the client */ -async function runClient(serverUrl: string, callbackPort: number) { +async function runClient(serverUrl: string, callbackPort: number, clean: boolean = false) { // Set up event emitter for auth flow const events = new EventEmitter() + // Get the server URL hash for lockfile operations + const serverUrlHash = getServerUrlHash(serverUrl) + + // Coordinate authentication with other instances + const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events) + // Create the OAuth client provider const authProvider = new NodeOAuthClientProvider({ serverUrl, callbackPort, clientName: 'MCP CLI Client', + clean, }) + // If auth was completed by another instance, just log that we'll use the auth from disk + if (skipBrowserAuth) { + log('Authentication was completed by another instance - will use tokens from disk...') + // TODO: remove, the callback is happening before the tokens are exchanged + // so we're slightly too early + await new Promise((res) => setTimeout(res, 1_000)) + } + // Create the client const client = new Client( { - name: 'mcp-cli', - version: '0.1.0', + name: 'mcp-remote', + version: MCP_REMOTE_VERSION, }, { - capabilities: { - sampling: {}, - }, + capabilities: {}, }, ) @@ -50,15 +68,15 @@ async function runClient(serverUrl: string, callbackPort: number) { // Set up message and error handlers transport.onmessage = (message) => { - console.log('Received message:', JSON.stringify(message, null, 2)) + log('Received message:', JSON.stringify(message, null, 2)) } transport.onerror = (error) => { - console.error('Transport error:', error) + log('Transport error:', error) } transport.onclose = () => { - console.log('Connection closed.') + log('Connection closed.') process.exit(0) } return transport @@ -66,16 +84,9 @@ async function runClient(serverUrl: string, callbackPort: number) { const transport = initTransport() - // Set up an HTTP server to handle OAuth callback - const { server, waitForAuthCode } = setupOAuthCallbackServer({ - port: callbackPort, - path: '/oauth/callback', - events, - }) - // Set up cleanup handler const cleanup = async () => { - console.log('\nClosing connection...') + log('\nClosing connection...') await client.close() server.close() } @@ -83,44 +94,44 @@ async function runClient(serverUrl: string, callbackPort: number) { // Try to connect try { - console.log('Connecting to server...') + log('Connecting to server...') await client.connect(transport) - console.log('Connected successfully!') + log('Connected successfully!') } catch (error) { if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { - console.log('Authentication required. Waiting for authorization...') + log('Authentication required. Waiting for authorization...') - // Wait for the authorization code from the callback + // Wait for the authorization code from the callback or another instance const code = await waitForAuthCode() try { - console.log('Completing authorization...') + log('Completing authorization...') await transport.finishAuth(code) // Reconnect after authorization with a new transport - console.log('Connecting after authorization...') + log('Connecting after authorization...') await client.connect(initTransport()) - console.log('Connected successfully!') + log('Connected successfully!') // Request tools list after auth - console.log('Requesting tools list...') + log('Requesting tools list...') const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) - console.log('Tools:', JSON.stringify(tools, null, 2)) + log('Tools:', JSON.stringify(tools, null, 2)) // Request resources list after auth - console.log('Requesting resource list...') + log('Requesting resource list...') const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) - console.log('Resources:', JSON.stringify(resources, null, 2)) + log('Resources:', JSON.stringify(resources, null, 2)) - console.log('Listening for messages. Press Ctrl+C to exit.') + log('Listening for messages. Press Ctrl+C to exit.') } catch (authError) { - console.error('Authorization error:', authError) + log('Authorization error:', authError) server.close() process.exit(1) } } else { - console.error('Connection error:', error) + log('Connection error:', error) server.close() process.exit(1) } @@ -128,29 +139,29 @@ async function runClient(serverUrl: string, callbackPort: number) { try { // Request tools list - console.log('Requesting tools list...') + log('Requesting tools list...') const tools = await client.request({ method: 'tools/list' }, ListToolsResultSchema) - console.log('Tools:', JSON.stringify(tools, null, 2)) + log('Tools:', JSON.stringify(tools, null, 2)) } catch (e) { - console.log('Error requesting tools list:', e) + log('Error requesting tools list:', e) } try { // Request resources list - console.log('Requesting resource list...') + log('Requesting resource list...') const resources = await client.request({ method: 'resources/list' }, ListResourcesResultSchema) - console.log('Resources:', JSON.stringify(resources, null, 2)) + log('Resources:', JSON.stringify(resources, null, 2)) } catch (e) { - console.log('Error requesting resources list:', e) + log('Error requesting resources list:', e) } - console.log('Listening for messages. Press Ctrl+C to exit.') + log('Listening for messages. Press Ctrl+C to exit.') } // Parse command-line arguments and run the client -parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [callback-port]') - .then(({ serverUrl, callbackPort }) => { - return runClient(serverUrl, callbackPort) +parseCommandLineArgs(process.argv.slice(2), 3333, 'Usage: npx tsx client.ts [--clean] [callback-port]') + .then(({ serverUrl, callbackPort, clean }) => { + return runClient(serverUrl, callbackPort, clean) }) .catch((error) => { console.error('Fatal error:', error) diff --git a/src/lib/coordination.ts b/src/lib/coordination.ts new file mode 100644 index 0000000..c173e7a --- /dev/null +++ b/src/lib/coordination.ts @@ -0,0 +1,188 @@ +import { checkLockfile, createLockfile, deleteLockfile, getConfigFilePath, LockfileData } from './mcp-auth-config' +import { EventEmitter } from 'events' +import { Server } from 'http' +import express from 'express' +import { AddressInfo } from 'net' +import { log, setupOAuthCallbackServerWithLongPoll } from './utils' + +/** + * Checks if a process with the given PID is running + * @param pid The process ID to check + * @returns True if the process is running, false otherwise + */ +export async function isPidRunning(pid: number): Promise { + try { + process.kill(pid, 0) // Doesn't kill the process, just checks if it exists + return true + } catch { + return false + } +} + +/** + * Checks if a lockfile is valid (process running and endpoint accessible) + * @param lockData The lockfile data + * @returns True if the lockfile is valid, false otherwise + */ +export async function isLockValid(lockData: LockfileData): Promise { + // Check if the lockfile is too old (over 30 minutes) + const MAX_LOCK_AGE = 30 * 60 * 1000 // 30 minutes + if (Date.now() - lockData.timestamp > MAX_LOCK_AGE) { + log('Lockfile is too old') + return false + } + + // Check if the process is still running + if (!(await isPidRunning(lockData.pid))) { + log('Process from lockfile is not running') + return false + } + + // Check if the endpoint is accessible + try { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 1000) + + const response = await fetch(`http://127.0.0.1:${lockData.port}/wait-for-auth?poll=false`, { + signal: controller.signal, + }) + + clearTimeout(timeout) + return response.status === 200 || response.status === 202 + } catch (error) { + log(`Error connecting to auth server: ${(error as Error).message}`) + return false + } +} + +/** + * Waits for authentication from another server instance + * @param port The port to connect to + * @returns True if authentication completed successfully, false otherwise + */ +export async function waitForAuthentication(port: number): Promise { + log(`Waiting for authentication from the server on port ${port}...`) + + try { + while (true) { + const url = `http://127.0.0.1:${port}/wait-for-auth` + log(`Querying: ${url}`) + const response = await fetch(url) + + if (response.status === 200) { + // Auth completed, but we don't return the code anymore + log(`Authentication completed by other instance`) + return true + } else if (response.status === 202) { + // Continue polling + log(`Authentication still in progress`) + await new Promise(resolve => setTimeout(resolve, 1000)) + } else { + log(`Unexpected response status: ${response.status}`) + return false + } + } + } catch (error) { + log(`Error waiting for authentication: ${(error as Error).message}`) + return false + } +} + +/** + * Coordinates authentication between multiple instances of the client/proxy + * @param serverUrlHash The hash of the server URL + * @param callbackPort The port to use for the callback server + * @param events The event emitter to use for signaling + * @returns An object with the server, waitForAuthCode function, and a flag indicating if browser auth can be skipped + */ +export async function coordinateAuth( + serverUrlHash: string, + callbackPort: number, + events: EventEmitter, +): Promise<{ server: Server; waitForAuthCode: () => Promise; skipBrowserAuth: boolean }> { + // Check for a lockfile + const lockData = await checkLockfile(serverUrlHash) + + // If there's a valid lockfile, try to use the existing auth process + if (lockData && (await isLockValid(lockData))) { + log(`Another instance is handling authentication on port ${lockData.port}`) + + try { + // Try to wait for the authentication to complete + const authCompleted = await waitForAuthentication(lockData.port) + if (authCompleted) { + log('Authentication completed by another instance') + + // Setup a dummy server - the client will use tokens directly from disk + const dummyServer = express().listen(0) // Listen on any available port + + // This shouldn't actually be called in normal operation, but provide it for API compatibility + const dummyWaitForAuthCode = () => { + log('WARNING: waitForAuthCode called in secondary instance - this is unexpected') + // Return a promise that never resolves - the client should use the tokens from disk instead + return new Promise(() => {}) + } + + return { + server: dummyServer, + waitForAuthCode: dummyWaitForAuthCode, + skipBrowserAuth: true, + } + } else { + log('Taking over authentication process...') + } + } catch (error) { + log(`Error waiting for authentication: ${error}`) + } + + // If we get here, the other process didn't complete auth successfully + await deleteLockfile(serverUrlHash) + } else if (lockData) { + // Invalid lockfile, delete its + log('Found invalid lockfile, deleting it') + await deleteLockfile(serverUrlHash) + } + + // Create our own lockfile + const { server, waitForAuthCode, authCompletedPromise } = setupOAuthCallbackServerWithLongPoll({ + port: callbackPort, + path: '/oauth/callback', + events, + }) + + // Get the actual port the server is running on + const address = server.address() as AddressInfo + const actualPort = address.port + + log(`Creating lockfile for server ${serverUrlHash} with process ${process.pid} on port ${actualPort}`) + await createLockfile(serverUrlHash, process.pid, actualPort) + + // Make sure lockfile is deleted on process exit + const cleanupHandler = async () => { + try { + log(`Cleaning up lockfile for server ${serverUrlHash}`) + await deleteLockfile(serverUrlHash) + } catch (error) { + log(`Error cleaning up lockfile: ${error}`) + } + } + + process.once('exit', () => { + try { + // Synchronous version for 'exit' event since we can't use async here + const configPath = getConfigFilePath(serverUrlHash, 'lock.json') + require('fs').unlinkSync(configPath) + } catch {} + }) + + // Also handle SIGINT separately + process.once('SIGINT', async () => { + await cleanupHandler() + }) + + return { + server, + waitForAuthCode, + skipBrowserAuth: false + } +} diff --git a/src/lib/mcp-auth-config.ts b/src/lib/mcp-auth-config.ts new file mode 100644 index 0000000..fce4219 --- /dev/null +++ b/src/lib/mcp-auth-config.ts @@ -0,0 +1,247 @@ +import path from 'path' +import os from 'os' +import fs from 'fs/promises' +import { log, MCP_REMOTE_VERSION } from './utils' + +/** + * MCP Remote Authentication Configuration + * + * This module handles the storage and retrieval of authentication-related data for MCP Remote. + * + * Configuration directory structure: + * - The config directory is determined by MCP_REMOTE_CONFIG_DIR env var or defaults to ~/.mcp-auth + * - Each file is prefixed with a hash of the server URL to separate configurations for different servers + * + * Files stored in the config directory: + * - {server_hash}_client_info.json: Contains OAuth client registration information + * - Format: OAuthClientInformation object with client_id and other registration details + * - {server_hash}_tokens.json: Contains OAuth access and refresh tokens + * - Format: OAuthTokens object with access_token, refresh_token, and expiration information + * - {server_hash}_code_verifier.txt: Contains the PKCE code verifier for the current OAuth flow + * - Format: Plain text string used for PKCE verification + * + * All JSON files are stored with 2-space indentation for readability. + */ + +/** + * Known configuration file names that might need to be cleaned + */ +export const knownConfigFiles = ['client_info.json', 'tokens.json', 'code_verifier.txt', 'lock.json'] + +/** + * Lockfile data structure + */ +export interface LockfileData { + pid: number + port: number + timestamp: number +} + +/** + * Creates a lockfile for the given server + * @param serverUrlHash The hash of the server URL + * @param pid The process ID + * @param port The port the server is running on + */ +export async function createLockfile(serverUrlHash: string, pid: number, port: number): Promise { + const lockData: LockfileData = { + pid, + port, + timestamp: Date.now(), + } + await writeJsonFile(serverUrlHash, 'lock.json', lockData) +} + +/** + * Checks if a lockfile exists for the given server + * @param serverUrlHash The hash of the server URL + * @returns The lockfile data or null if it doesn't exist + */ +export async function checkLockfile(serverUrlHash: string): Promise { + try { + const lockfile = await readJsonFile(serverUrlHash, 'lock.json', { + async parseAsync(data: any) { + if (typeof data !== 'object' || data === null) return null + if (typeof data.pid !== 'number' || typeof data.port !== 'number' || typeof data.timestamp !== 'number') { + return null + } + return data as LockfileData + }, + }) + return lockfile || null + } catch { + return null + } +} + +/** + * Deletes the lockfile for the given server + * @param serverUrlHash The hash of the server URL + */ +export async function deleteLockfile(serverUrlHash: string): Promise { + await deleteConfigFile(serverUrlHash, 'lock.json') +} + +/** + * Deletes all known configuration files for a specific server + * @param serverUrlHash The hash of the server URL + */ +export async function cleanServerConfig(serverUrlHash: string): Promise { + log(`Cleaning configuration files for server: ${serverUrlHash}`) + for (const filename of knownConfigFiles) { + await deleteConfigFile(serverUrlHash, filename) + } +} + +/** + * Gets the configuration directory path + * @returns The path to the configuration directory + */ +export function getConfigDir(): string { + const baseConfigDir = process.env.MCP_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.mcp-auth') + // Add a version subdirectory so we don't need to worry about backwards/forwards compatibility yet + return path.join(baseConfigDir, `mcp-remote-${MCP_REMOTE_VERSION}`) +} + +/** + * Ensures the configuration directory exists + */ +export async function ensureConfigDir(): Promise { + try { + const configDir = getConfigDir() + await fs.mkdir(configDir, { recursive: true }) + } catch (error) { + log('Error creating config directory:', error) + throw error + } +} + +/** + * Gets the file path for a config file + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file + * @returns The absolute file path + */ +export function getConfigFilePath(serverUrlHash: string, filename: string): string { + const configDir = getConfigDir() + return path.join(configDir, `${serverUrlHash}_${filename}`) +} + +/** + * Deletes a config file if it exists + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to delete + */ +export async function deleteConfigFile(serverUrlHash: string, filename: string): Promise { + try { + const filePath = getConfigFilePath(serverUrlHash, filename) + await fs.unlink(filePath) + } catch (error) { + // Ignore if file doesn't exist + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + log(`Error deleting ${filename}:`, error) + } + } +} + +/** + * Reads a JSON file and parses it with the provided schema + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to read + * @param schema The schema to validate against + * @param clean Whether to clean (delete) before reading + * @returns The parsed file content or undefined if the file doesn't exist + */ +export async function readJsonFile( + serverUrlHash: string, + filename: string, + schema: any, + clean: boolean = false, +): Promise { + try { + await ensureConfigDir() + + // If clean flag is set, delete the file before trying to read it + if (clean) { + await deleteConfigFile(serverUrlHash, filename) + return undefined + } + + const filePath = getConfigFilePath(serverUrlHash, filename) + const content = await fs.readFile(filePath, 'utf-8') + const result = await schema.parseAsync(JSON.parse(content)) + // console.log({ filename: result }) + return result + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + // console.log(`File ${filename} does not exist`) + return undefined + } + log(`Error reading ${filename}:`, error) + return undefined + } +} + +/** + * Writes a JSON object to a file + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to write + * @param data The data to write + */ +export async function writeJsonFile(serverUrlHash: string, filename: string, data: any): Promise { + try { + await ensureConfigDir() + const filePath = getConfigFilePath(serverUrlHash, filename) + await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8') + } catch (error) { + log(`Error writing ${filename}:`, error) + throw error + } +} + +/** + * Reads a text file + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to read + * @param errorMessage Optional custom error message + * @param clean Whether to clean (delete) before reading + * @returns The file content as a string + */ +export async function readTextFile( + serverUrlHash: string, + filename: string, + errorMessage?: string, + clean: boolean = false, +): Promise { + try { + await ensureConfigDir() + + // If clean flag is set, delete the file before trying to read it + if (clean) { + await deleteConfigFile(serverUrlHash, filename) + throw new Error('File deleted due to clean flag') + } + + const filePath = getConfigFilePath(serverUrlHash, filename) + return await fs.readFile(filePath, 'utf-8') + } catch (error) { + throw new Error(errorMessage || `Error reading ${filename}`) + } +} + +/** + * Writes a text string to a file + * @param serverUrlHash The hash of the server URL + * @param filename The name of the file to write + * @param text The text to write + */ +export async function writeTextFile(serverUrlHash: string, filename: string, text: string): Promise { + try { + await ensureConfigDir() + const filePath = getConfigFilePath(serverUrlHash, filename) + await fs.writeFile(filePath, text, 'utf-8') + } catch (error) { + log(`Error writing ${filename}:`, error) + throw error + } +} diff --git a/src/lib/node-oauth-client-provider.ts b/src/lib/node-oauth-client-provider.ts new file mode 100644 index 0000000..c02ce97 --- /dev/null +++ b/src/lib/node-oauth-client-provider.ts @@ -0,0 +1,125 @@ +import open from 'open' +import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientInformationSchema, + OAuthTokens, + OAuthTokensSchema, +} from '@modelcontextprotocol/sdk/shared/auth.js' +import type { OAuthProviderOptions } from './types' +import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, cleanServerConfig } from './mcp-auth-config' +import { getServerUrlHash, log } from './utils' + +/** + * Implements the OAuthClientProvider interface for Node.js environments. + * Handles OAuth flow and token storage for MCP clients. + */ +export class NodeOAuthClientProvider implements OAuthClientProvider { + private serverUrlHash: string + private callbackPath: string + private clientName: string + private clientUri: string + + /** + * Creates a new NodeOAuthClientProvider + * @param options Configuration options for the provider + */ + constructor(readonly options: OAuthProviderOptions) { + this.serverUrlHash = getServerUrlHash(options.serverUrl) + this.callbackPath = options.callbackPath || '/oauth/callback' + this.clientName = options.clientName || 'MCP CLI Client' + this.clientUri = options.clientUri || 'https://github.com/modelcontextprotocol/mcp-cli' + + // If clean flag is set, proactively clean all config files for this server + if (options.clean) { + cleanServerConfig(this.serverUrlHash).catch((err) => { + log('Error cleaning server config:', err) + }) + } + } + + get redirectUrl(): string { + return `http://127.0.0.1:${this.options.callbackPort}${this.callbackPath}` + } + + 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, + } + } + + /** + * Gets the client information if it exists + * @returns The client information or undefined + */ + async clientInformation(): Promise { + // log('Reading client info') + return readJsonFile(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema, this.options.clean) + } + + /** + * Saves client information + * @param clientInformation The client information to save + */ + async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise { + // log('Saving client info') + await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation) + } + + /** + * Gets the OAuth tokens if they exist + * @returns The OAuth tokens or undefined + */ + async tokens(): Promise { + // log('Reading tokens') + // console.log(new Error().stack) + return readJsonFile(this.serverUrlHash, 'tokens.json', OAuthTokensSchema, this.options.clean) + } + + /** + * Saves OAuth tokens + * @param tokens The tokens to save + */ + async saveTokens(tokens: OAuthTokens): Promise { + // log('Saving tokens') + await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens) + } + + /** + * Redirects the user to the authorization URL + * @param authorizationUrl The URL to redirect to + */ + async redirectToAuthorization(authorizationUrl: URL): Promise { + log(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`) + try { + await open(authorizationUrl.toString()) + log('Browser opened automatically.') + } catch (error) { + log('Could not open browser automatically. Please copy and paste the URL above into your browser.') + } + } + + /** + * Saves the PKCE code verifier + * @param codeVerifier The code verifier to save + */ + async saveCodeVerifier(codeVerifier: string): Promise { + // log('Saving code verifier') + await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier) + } + + /** + * Gets the PKCE code verifier + * @returns The code verifier + */ + async codeVerifier(): Promise { + // log('Reading code verifier') + return await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session', this.options.clean) + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 188fccb..e719905 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -16,6 +16,8 @@ export interface OAuthProviderOptions { clientName?: string /** Client URI to use for OAuth registration */ clientUri?: string + /** Whether to clean stored configuration before reading */ + clean?: boolean } /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2b09f57..a37fe61 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,8 +1,19 @@ 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 { OAuthCallbackServerOptions } from './types' +import express from 'express' +import net from 'net' +import crypto from 'crypto' + +// Package version from package.json +export const MCP_REMOTE_VERSION = require('../../package.json').version const pid = process.pid +export function log(str: string, ...rest: unknown[]) { + // Using stderr so that it doesn't interfere with stdout + console.error(`[${pid}] ${str}`, ...rest) +} /** * Creates a bidirectional proxy between two transports @@ -14,13 +25,13 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo transportToClient.onmessage = (message) => { // @ts-expect-error TODO - console.error('[Local→Remote]', message.method || message.id) + log('[Local→Remote]', message.method || message.id) transportToServer.send(message).catch(onServerError) } transportToServer.onmessage = (message) => { // @ts-expect-error TODO: fix this type - console.error('[Remote→Local]', message.method || message.id) + log('[Remote→Local]', message.method || message.id) transportToClient.send(message).catch(onClientError) } @@ -45,11 +56,11 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo transportToServer.onerror = onServerError function onClientError(error: Error) { - console.error('Error from local client:', error) + log('Error from local client:', error) } function onServerError(error: Error) { - console.error('Error from remote server:', error) + log('Error from remote server:', error) } } @@ -58,44 +69,256 @@ export function mcpProxy({ transportToClient, transportToServer }: { transportTo * @param serverUrl The URL of the remote server * @param authProvider The OAuth client provider * @param waitForAuthCode Function to wait for the auth code + * @param skipBrowserAuth Whether to skip browser auth and use shared auth * @returns The connected SSE client transport */ export async function connectToRemoteServer( serverUrl: string, authProvider: OAuthClientProvider, waitForAuthCode: () => Promise, + skipBrowserAuth: boolean = false, ): Promise { - console.error(`[${pid}] Connecting to remote server: ${serverUrl}`) + log(`[${pid}] 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') + log('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...') + if (skipBrowserAuth) { + log('Authentication required but skipping browser auth - using shared auth') + } else { + log('Authentication required. Waiting for authorization...') + } // Wait for the authorization code from the callback const code = await waitForAuthCode() try { - console.error('Completing authorization...') + log('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') + log('Connected to remote server after authentication') return newTransport } catch (authError) { - console.error('Authorization error:', authError) + log('Authorization error:', authError) throw authError } } else { - console.error('Connection error:', error) + log('Connection error:', error) throw error } } } + +/** + * Sets up an Express server to handle OAuth callbacks + * @param options The server options + * @returns An object with the server, authCode, and waitForAuthCode function + */ +export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServerOptions) { + let authCode: string | null = null + const app = express() + + // Create a promise to track when auth is completed + let authCompletedResolve: (code: string) => void + const authCompletedPromise = new Promise((resolve) => { + authCompletedResolve = resolve + }) + + // Long-polling endpoint + app.get('/wait-for-auth', (req, res) => { + if (authCode) { + // Auth already completed - just return 200 without the actual code + // Secondary instances will read tokens from disk + log('Auth already completed, returning 200') + res.status(200).send('Authentication completed') + return + } + + if (req.query.poll === 'false') { + log('Client requested no long poll, responding with 202') + res.status(202).send('Authentication in progress') + return + } + + // Long poll - wait for up to 30 seconds + const longPollTimeout = setTimeout(() => { + log('Long poll timeout reached, responding with 202') + res.status(202).send('Authentication in progress') + }, 30000) + + // If auth completes while we're waiting, send the response immediately + authCompletedPromise + .then(() => { + clearTimeout(longPollTimeout) + if (!res.headersSent) { + log('Auth completed during long poll, responding with 200') + res.status(200).send('Authentication completed') + } + }) + .catch(() => { + clearTimeout(longPollTimeout) + if (!res.headersSent) { + log('Auth failed during long poll, responding with 500') + res.status(500).send('Authentication failed') + } + }) + }) + + // OAuth callback endpoint + app.get(options.path, (req, res) => { + const code = req.query.code as string | undefined + if (!code) { + res.status(400).send('Error: No authorization code received') + return + } + + authCode = code + log('Auth code received, resolving promise') + authCompletedResolve(code) + + res.send('Authorization successful! You may close this window and return to the CLI.') + + // Notify main flow that auth code is available + options.events.emit('auth-code-received', code) + }) + + const server = app.listen(options.port, () => { + log(`OAuth callback server running at http://127.0.0.1:${options.port}`) + }) + + const waitForAuthCode = (): Promise => { + return new Promise((resolve) => { + if (authCode) { + resolve(authCode) + return + } + + options.events.once('auth-code-received', (code) => { + resolve(code) + }) + }) + } + + return { server, authCode, waitForAuthCode, authCompletedPromise } +} + +/** + * Sets up an Express server to handle OAuth callbacks + * @param options The server options + * @returns An object with the server, authCode, and waitForAuthCode function + */ +export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { + const { server, authCode, waitForAuthCode } = setupOAuthCallbackServerWithLongPoll(options) + return { server, authCode, waitForAuthCode } +} + +/** + * Finds an available port on the local machine + * @param preferredPort Optional preferred port to try first + * @returns A promise that resolves to an available port number + */ +export async function findAvailablePort(preferredPort?: number): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer() + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + // If preferred port is in use, get a random port + server.listen(0) + } else { + reject(err) + } + }) + + server.on('listening', () => { + const { port } = server.address() as net.AddressInfo + server.close(() => { + resolve(port) + }) + }) + + // Try preferred port first, or get a random port + server.listen(preferredPort || 0) + }) +} + +/** + * Parses command line arguments for MCP clients and proxies + * @param args Command line arguments + * @param defaultPort Default port for the callback server if specified port is unavailable + * @param usage Usage message to show on error + * @returns A promise that resolves to an object with parsed serverUrl, callbackPort, and clean flag + */ +export async function parseCommandLineArgs(args: string[], defaultPort: number, usage: string) { + // Check for --clean flag + const cleanIndex = args.indexOf('--clean') + const clean = cleanIndex !== -1 + + // Remove the flag from args if it exists + if (clean) { + args.splice(cleanIndex, 1) + } + + const serverUrl = args[0] + const specifiedPort = args[1] ? parseInt(args[1]) : undefined + + if (!serverUrl) { + log(usage) + process.exit(1) + } + + const url = new URL(serverUrl) + const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:' + + if (!(url.protocol == 'https:' || isLocalhost)) { + log(usage) + process.exit(1) + } + + // Use the specified port, or find an available one + const callbackPort = specifiedPort || (await findAvailablePort(defaultPort)) + + if (specifiedPort) { + log(`Using specified callback port: ${callbackPort}`) + } else { + log(`Using automatically selected callback port: ${callbackPort}`) + } + + if (clean) { + log('Clean mode enabled: config files will be reset before reading') + } + + return { serverUrl, callbackPort, clean } +} + +/** + * Sets up signal handlers for graceful shutdown + * @param cleanup Cleanup function to run on shutdown + */ +export function setupSignalHandlers(cleanup: () => Promise) { + process.on('SIGINT', async () => { + log('\nShutting down...') + await cleanup() + process.exit(0) + }) + + // Keep the process alive + process.stdin.resume() +} + +/** + * Generates a hash for the server URL to use in filenames + * @param serverUrl The server URL to hash + * @returns The hashed server URL + */ +export function getServerUrlHash(serverUrl: string): string { + return crypto.createHash('md5').update(serverUrl).digest('hex') +} diff --git a/src/cli/proxy.ts b/src/proxy.ts similarity index 56% rename from src/cli/proxy.ts rename to src/proxy.ts index d705b35..4c8d75c 100644 --- a/src/cli/proxy.ts +++ b/src/proxy.ts @@ -4,43 +4,55 @@ * MCP Proxy with OAuth support * A bidirectional proxy between a local STDIO MCP server and a remote SSE server with OAuth authentication. * - * Run with: npx tsx proxy.ts https://example.remote/server [callback-port] + * Run with: npx tsx proxy.ts [--clean] https://example.remote/server [callback-port] + * + * Options: + * --clean: Deletes stored configuration before reading, ensuring a fresh session * * If callback-port is not specified, an available port will be automatically selected. */ import { EventEmitter } from 'events' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js' -import { connectToRemoteServer, mcpProxy } from '../lib/utils.js' +import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupSignalHandlers, getServerUrlHash } from './lib/utils' +import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' +import { coordinateAuth } from './lib/coordination' /** * Main function to run the proxy */ -async function runProxy(serverUrl: string, callbackPort: number) { +async function runProxy(serverUrl: string, callbackPort: number, clean: boolean = false) { // Set up event emitter for auth flow const events = new EventEmitter() + // Get the server URL hash for lockfile operations + const serverUrlHash = getServerUrlHash(serverUrl) + + // Coordinate authentication with other instances + const { server, waitForAuthCode, skipBrowserAuth } = await coordinateAuth(serverUrlHash, callbackPort, events) + // Create the OAuth client provider const authProvider = new NodeOAuthClientProvider({ serverUrl, callbackPort, clientName: 'MCP CLI Proxy', + clean, }) + // If auth was completed by another instance, just log that we'll use the auth from disk + if (skipBrowserAuth) { + log('Authentication was completed by another instance - will use tokens from disk') + // TODO: remove, the callback is happening before the tokens are exchanged + // so we're slightly too early + await new Promise((res) => setTimeout(res, 1_000)) + } + // Create the STDIO transport for local connections const localTransport = new StdioServerTransport() - // Set up an HTTP server to handle OAuth callback - const { server, waitForAuthCode } = setupOAuthCallbackServer({ - port: callbackPort, - path: '/oauth/callback', - events, - }) - try { // Connect to remote server with authentication - const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode) + const remoteTransport = await connectToRemoteServer(serverUrl, authProvider, waitForAuthCode, skipBrowserAuth) // Set up bidirectional proxy between local and remote transports mcpProxy({ @@ -50,9 +62,9 @@ async function runProxy(serverUrl: string, callbackPort: number) { // Start the local STDIO server await localTransport.start() - console.error('Local STDIO server running') - console.error('Proxy established successfully between local STDIO and remote SSE') - console.error('Press Ctrl+C to exit') + log('Local STDIO server running') + log('Proxy established successfully between local STDIO and remote SSE') + log('Press Ctrl+C to exit') // Setup cleanup handler const cleanup = async () => { @@ -62,9 +74,9 @@ async function runProxy(serverUrl: string, callbackPort: number) { } setupSignalHandlers(cleanup) } catch (error) { - console.error('Fatal error:', error) + log('Fatal error:', error) if (error instanceof Error && error.message.includes('self-signed certificate in certificate chain')) { - console.error(`You may be behind a VPN! + log(`You may be behind a VPN! If you are behind a VPN, you can try setting the NODE_EXTRA_CA_CERTS environment variable to point to the CA certificate file. If using claude_desktop_config.json, this might look like: @@ -91,11 +103,11 @@ to the CA certificate file. If using claude_desktop_config.json, this might look } // Parse command-line arguments and run the proxy -parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [callback-port]') - .then(({ serverUrl, callbackPort }) => { - return runProxy(serverUrl, callbackPort) +parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [--clean] [callback-port]') + .then(({ serverUrl, callbackPort, clean }) => { + return runProxy(serverUrl, callbackPort, clean) }) .catch((error) => { - console.error('Fatal error:', error) + log('Fatal error:', error) process.exit(1) }) diff --git a/src/react/index.ts b/src/react/index.ts deleted file mode 100644 index 43f8a4f..0000000 --- a/src/react/index.ts +++ /dev/null @@ -1,1243 +0,0 @@ -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 { - OAuthClientProvider, - discoverOAuthMetadata, - exchangeAuthorization, - startAuthorization, -} from '@modelcontextprotocol/sdk/client/auth.js' -import { OAuthClientInformation, OAuthMetadata, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js' - -function assert(condition: unknown, message: string): asserts condition { - if (!condition) { - throw new Error(message) - } -} - -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 - /** - * Clear all localStorage items for this server - */ - clearStorage: () => void -} - -type StoredState = { - authorizationUrl: string - metadata: OAuthMetadata - serverUrlHash: string - expiry: number -} - -/** - * Browser-compatible OAuth client provider for MCP - */ -class BrowserOAuthClientProvider implements OAuthClientProvider { - private storageKeyPrefix: string - serverUrlHash: string - private clientName: string - private clientUri: string - private callbackUrl: string - // Store additional options for popup windows - private popupFeatures: string - - constructor( - readonly serverUrl: string, - options: { - storageKeyPrefix?: string - clientName?: string - clientUri?: string - callbackUrl?: string - popupFeatures?: 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.popupFeatures = options.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes' - } - - 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, - } - } - - /** - * Clears all storage items related to this server - * @returns The number of items cleared - */ - clearStorage(): number { - const prefix = `${this.storageKeyPrefix}_${this.serverUrlHash}` - const keysToRemove = [] - - // Find all keys that match the prefix - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i) - if (key && key.startsWith(prefix)) { - keysToRemove.push(key) - } - } - - // Also check for any state keys - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i) - if (key && key.startsWith(`${this.storageKeyPrefix}:state_`)) { - // Load state to check if it's for this server - try { - const state = JSON.parse(localStorage.getItem(key) || '{}') - if (state.serverUrlHash === this.serverUrlHash) { - keysToRemove.push(key) - } - } catch (e) { - // Ignore JSON parse errors - } - } - } - - // Remove all matching keys - keysToRemove.forEach((key) => localStorage.removeItem(key)) - - return keysToRemove.length - } - - 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) - } - - 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)) - } - - /** - * Redirect method that matches the interface expected by OAuthClientProvider - */ - async redirectToAuthorization(authorizationUrl: URL): Promise { - // Simply open the URL in the current window - console.log('WE WERE ABOUT TO REDIRECT BUT WE DONT DO THAT HERE') - // window.location.href = authorizationUrl.toString() - } - - /** - * Extended popup-based authorization method specific to browser environments - */ - async openAuthorizationPopup( - authorizationUrl: URL, - metadata: OAuthMetadata, - ): Promise<{ success: boolean; popupBlocked?: boolean; url: string }> { - // Use existing state parameter if it exists in the URL - const existingState = authorizationUrl.searchParams.get('state') - - if (!existingState) { - // This should not happen as startAuthFlow should've added state - // But if it doesn't exist, add it as a fallback - 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() - - // 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', this.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 - } -} - -/** - * Class to encapsulate all MCP client functionality, - * including authentication flow and connection management - */ -class McpClient { - // State - private _state: UseMcpResult['state'] = 'discovering' - private _error?: string - private _tools: Tool[] = [] - private _log: UseMcpResult['log'] = [] - private _authUrl?: string - - // Client and transport - private client: Client | null = null - private transport: SSEClientTransport | null = null - private authProvider: BrowserOAuthClientProvider | undefined = undefined - - // Authentication state - private metadata?: OAuthMetadata - private authUrlRef?: URL - private authState?: string - private codeVerifier?: string - private connecting = false - - // Update callbacks - private onStateChange: (state: UseMcpResult['state']) => void - private onToolsChange: (tools: Tool[]) => void - private onErrorChange: (error?: string) => void - private onLogChange: (log: UseMcpResult['log']) => void - private onAuthUrlChange: (authUrl?: string) => void - - constructor( - private url: string, - private options: { - clientName?: string - clientUri?: string - callbackUrl?: string - storageKeyPrefix?: string - clientConfig?: { - name?: string - version?: string - } - debug?: boolean - autoRetry?: boolean | number - autoReconnect?: boolean | number - popupFeatures?: string - }, - callbacks: { - onStateChange: (state: UseMcpResult['state']) => void - onToolsChange: (tools: Tool[]) => void - onErrorChange: (error?: string) => void - onLogChange: (log: UseMcpResult['log']) => void - onAuthUrlChange: (authUrl?: string) => void - }, - ) { - // Initialize callbacks - this.onStateChange = callbacks.onStateChange - this.onToolsChange = callbacks.onToolsChange - this.onErrorChange = callbacks.onErrorChange - this.onLogChange = callbacks.onLogChange - this.onAuthUrlChange = callbacks.onAuthUrlChange - - // Initialize auth provider - this.initAuthProvider() - } - - get state(): UseMcpResult['state'] { - return this._state - } - - get tools(): Tool[] { - return this._tools - } - - get error(): string | undefined { - return this._error - } - - get log(): UseMcpResult['log'] { - return this._log - } - - get authUrl(): string | undefined { - return this._authUrl - } - - /** - * Initialize the auth provider - */ - private initAuthProvider(): void { - if (!this.authProvider) { - this.authProvider = new BrowserOAuthClientProvider(this.url, { - storageKeyPrefix: this.options.storageKeyPrefix, - clientName: this.options.clientName, - clientUri: this.options.clientUri, - callbackUrl: this.options.callbackUrl, - }) - } - } - - /** - * Add a log entry - */ - private addLog(level: 'debug' | 'info' | 'warn' | 'error', message: string): void { - if (level === 'debug' && !this.options.debug) return - this._log = [...this._log, { level, message }] - this.onLogChange(this._log) - } - - /** - * Update the state - */ - private setState(state: UseMcpResult['state']): void { - this._state = state - this.onStateChange(state) - } - - /** - * Update the error - */ - private setError(error?: string): void { - this._error = error - this.onErrorChange(error) - } - - /** - * Update the tools - */ - private setTools(tools: Tool[]): void { - this._tools = tools - this.onToolsChange(tools) - } - - /** - * Update the auth URL - */ - private setAuthUrl(authUrl?: string): void { - this._authUrl = authUrl - this.onAuthUrlChange(authUrl) - } - - /** - * Handle OAuth discovery and authentication - */ - private async discoverOAuthAndAuthenticate(error: Error): Promise { - try { - // Discover OAuth metadata now that we know we need it - if (!this.metadata) { - this.addLog('info', 'Discovering OAuth metadata...') - this.metadata = await discoverOAuthMetadata(this.url) - this.addLog('debug', `OAuth metadata: ${this.metadata ? 'Found' : 'Not available'}`) - } - - // If metadata is found, start auth flow - if (this.metadata) { - this.setState('authenticating') - - try { - // Start authentication process - await this.handleAuthentication() - - // After successful auth, retry connection - // Important: We need to fully disconnect and reconnect - await this.disconnect() - await this.connect() - } catch (authErr) { - this.addLog('error', `Authentication error: ${authErr instanceof Error ? authErr.message : String(authErr)}`) - this.setState('failed') - this.setError(`Authentication failed: ${authErr instanceof Error ? authErr.message : String(authErr)}`) - this.connecting = false - } - } else { - // No OAuth metadata available - this.setState('failed') - this.setError(`Authentication required but no OAuth metadata found: ${error.message}`) - this.connecting = false - } - } catch (oauthErr) { - this.addLog('error', `OAuth discovery error: ${oauthErr instanceof Error ? oauthErr.message : String(oauthErr)}`) - this.setState('failed') - this.setError(`Authentication setup failed: ${oauthErr instanceof Error ? oauthErr.message : String(oauthErr)}`) - this.connecting = false - } - } - - /** - * Connect to the MCP server - */ - async connect(): Promise { - // Prevent multiple simultaneous connection attempts - if (this.connecting) return - this.connecting = true - - try { - this.setState('discovering') - this.setError(undefined) - - // Create MCP client - this.client = new Client( - { - name: this.options.clientConfig?.name || 'mcp-react-client', - version: this.options.clientConfig?.version || '0.1.0', - }, - { - capabilities: { - sampling: {}, - }, - }, - ) - - // Create SSE transport - this.setState('connecting') - this.addLog('info', 'Creating transport...') - - const serverUrl = new URL(this.url) - this.transport = new SSEClientTransport(serverUrl, { - authProvider: this.authProvider, - }) - - // Set up transport handlers - this.transport.onmessage = (message: JSONRPCMessage) => { - // @ts-expect-error TODO: fix this type - this.addLog('debug', `Received message: ${message.method || message.id}`) - } - - this.transport.onerror = (err: Error) => { - this.addLog('error', `Transport error: ${err.message}`) - - if (err.message.includes('Unauthorized')) { - // Only discover OAuth metadata and authenticate if we get a 401 - this.discoverOAuthAndAuthenticate(err) - } else { - this.setState('failed') - this.setError(`Connection error: ${err.message}`) - this.connecting = false - } - } - - this.transport.onclose = () => { - this.addLog('info', 'Connection closed') - // If we were previously connected, try to reconnect - if (this.state === 'ready' && this.options.autoReconnect) { - const delay = typeof this.options.autoReconnect === 'number' ? this.options.autoReconnect : 3000 - this.addLog('info', `Will reconnect in ${delay}ms...`) - setTimeout(() => { - this.disconnect().then(() => this.connect()) - }, delay) - } - } - - // Try connecting transport - try { - this.addLog('info', 'Starting transport...') - // await this.transport.start() - } catch (err) { - this.addLog('error', `Transport start error: ${err instanceof Error ? err.message : String(err)}`) - - if (err instanceof Error && err.message.includes('Unauthorized')) { - // Only discover OAuth and authenticate if we get a 401 - await this.discoverOAuthAndAuthenticate(err) - return // Important: Return here to avoid proceeding with the unauthorized connection - } else { - this.setState('failed') - this.setError(`Connection error: ${err instanceof Error ? err.message : String(err)}`) - this.connecting = false - return - } - } - - // Connect client - try { - this.addLog('info', 'Connecting client...') - this.setState('loading') - await this.client.connect(this.transport) - this.addLog('info', 'Client connected') - - // Load tools - try { - this.addLog('info', 'Loading tools...') - const toolsResponse = await this.client.request({ method: 'tools/list' }, ListToolsResultSchema) - this.setTools(toolsResponse.tools) - this.addLog('info', `Loaded ${toolsResponse.tools.length} tools`) - - // Connection completed successfully - this.setState('ready') - this.connecting = false - } catch (toolErr) { - this.addLog('error', `Error loading tools: ${toolErr instanceof Error ? toolErr.message : String(toolErr)}`) - // We're still connected, just couldn't load tools - this.setState('ready') - this.connecting = false - } - } catch (connectErr) { - this.addLog('error', `Client connect error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`) - - if (connectErr instanceof Error && connectErr.message.includes('Unauthorized')) { - // Only discover OAuth and authenticate if we get a 401 - await this.discoverOAuthAndAuthenticate(connectErr) - } else { - this.setState('failed') - this.setError(`Connection error: ${connectErr instanceof Error ? connectErr.message : String(connectErr)}`) - this.connecting = false - } - } - } catch (err) { - this.addLog('error', `Unexpected error: ${err instanceof Error ? err.message : String(err)}`) - this.setState('failed') - this.setError(`Unexpected error: ${err instanceof Error ? err.message : String(err)}`) - this.connecting = false - } - } - - /** - * Disconnect from the MCP server - */ - async disconnect(): Promise { - if (this.client) { - try { - await this.client.close() - } catch (err) { - this.addLog('error', `Error closing client: ${err instanceof Error ? err.message : String(err)}`) - } - this.client = null - } - - if (this.transport) { - try { - await this.transport.close() - } catch (err) { - this.addLog('error', `Error closing transport: ${err instanceof Error ? err.message : String(err)}`) - } - this.transport = null - } - - this.connecting = false - this.setState('discovering') - this.setTools([]) - this.setError(undefined) - } - - /** - * Start the auth flow and get the auth URL - */ - async startAuthFlow(): Promise { - if (!this.authProvider || !this.metadata) { - throw new Error('Auth provider or metadata not available') - } - - this.addLog('info', 'Starting authentication flow...') - - // Check if we have client info - let clientInfo = await this.authProvider.clientInformation() - - if (!clientInfo) { - // Register client dynamically - this.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 - this.addLog('info', 'Preparing authorization...') - const { authorizationUrl, codeVerifier } = await startAuthorization(this.url, { - metadata: this.metadata, - clientInformation: clientInfo, - redirectUrl: this.authProvider.redirectUrl, - }) - - // Save code verifier and auth URL for later use - await this.authProvider.saveCodeVerifier(codeVerifier) - this.codeVerifier = codeVerifier - - // Generate state parameter that will be used for both popup and manual flows - const state = Math.random().toString(36).substring(2) - const stateKey = `${this.options.storageKeyPrefix}:state_${state}` - - // Store state for later retrieval - localStorage.setItem( - stateKey, - JSON.stringify({ - authorizationUrl: authorizationUrl.toString(), - metadata: this.metadata, - serverUrlHash: this.authProvider.serverUrlHash, - expiry: +new Date() + 1000 * 60 * 5 /* 5 minutes */, - } as StoredState), - ) - - // Add state to the URL - authorizationUrl.searchParams.set('state', state) - - // Store the state and URL for later use - this.authState = state - this.authUrlRef = authorizationUrl - - // Set manual auth URL (already includes state parameter) - this.setAuthUrl(authorizationUrl.toString()) - - return authorizationUrl - } - - /** - * Handle authentication flow - */ - async handleAuthentication(): Promise { - if (!this.authProvider) { - throw new Error('Auth provider not available') - } - - // Get or create the auth URL - if (!this.authUrlRef) { - try { - await this.startAuthFlow() - } catch (err) { - this.addLog('error', `Failed to start auth flow: ${err instanceof Error ? err.message : String(err)}`) - throw err - } - } - - if (!this.authUrlRef) { - throw new Error('Failed to create authorization URL') - } - - // Set up listener for post-auth message - const authPromise = new Promise((resolve, reject) => { - let pollIntervalId: number | undefined - - const timeoutId = setTimeout( - () => { - window.removeEventListener('message', messageHandler) - if (pollIntervalId) clearTimeout(pollIntervalId) - 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) - if (pollIntervalId) clearTimeout(pollIntervalId) - - resolve(event.data.code) - } - } - - window.addEventListener('message', messageHandler) - - // Add polling fallback to check for tokens in localStorage - const pollForTokens = () => { - try { - // Check if tokens have appeared in localStorage - const tokensKey = this.authProvider!.getKey('tokens') - const storedTokens = localStorage.getItem(tokensKey) - - if (storedTokens) { - // Tokens found, clean up and resolve - window.removeEventListener('message', messageHandler) - clearTimeout(timeoutId) - if (pollIntervalId) clearTimeout(pollIntervalId) - - // Parse tokens to make sure they're valid - const tokens = JSON.parse(storedTokens) - if (tokens.access_token) { - console.log('Found tokens in localStorage via polling') - // Resolve with an object that indicates tokens are already available - // This will signal to handleAuthCompletion that no token exchange is needed - resolve('TOKENS_ALREADY_EXCHANGED') - } - } - } catch (err) { - // Error during polling, continue anyway - console.error(err) - } - } - - // Start polling every 500ms using setTimeout for recursive polling - const poll = () => { - pollIntervalId = setTimeout(poll, 500) as unknown as number - pollForTokens() - } - - poll() // Start the polling - }) - - // Redirect to authorization - this.addLog('info', 'Opening authorization window...') - assert(this.metadata, 'Metadata not available') - const redirectResult = await this.authProvider.openAuthorizationPopup(this.authUrlRef, this.metadata) - - if (!redirectResult.success) { - // Popup was blocked - this.setState('failed') - this.setError('Authentication popup was blocked by the browser. Please click the link to authenticate in a new window.') - this.setAuthUrl(redirectResult.url) - this.addLog('warn', 'Authentication popup was blocked. User needs to manually authorize.') - throw new Error('Authentication popup blocked') - } - - // Wait for auth to complete - this.addLog('info', 'Waiting for authorization...') - const code = await authPromise - this.addLog('info', 'Authorization code received') - - return code - } - - /** - * Handle authentication completion - * @param code - The authorization code or special token indicator - */ - async handleAuthCompletion(code: string): Promise { - if (!this.authProvider || !this.transport) { - throw new Error('Authentication context not available') - } - - try { - // Check if this is our special token indicator - if (code === 'TOKENS_ALREADY_EXCHANGED') { - this.addLog('info', 'Using already exchanged tokens from localStorage') - // No need to exchange tokens, they're already in localStorage - } else { - // We received an authorization code that needs to be exchanged - this.addLog('info', 'Finishing authorization with code exchange...') - await this.transport.finishAuth(code) - this.addLog('info', 'Authorization code exchanged for tokens') - } - - this.addLog('info', 'Authorization completed') - - // Reset auth URL state - this.authUrlRef = undefined - this.setAuthUrl(undefined) - - // Reconnect with the new auth token - important to do a full disconnect/connect cycle - await this.disconnect() - await this.connect() - } catch (err) { - this.addLog('error', `Auth completion error: ${err instanceof Error ? err.message : String(err)}`) - this.setState('failed') - this.setError(`Authentication failed: ${err instanceof Error ? err.message : String(err)}`) - } - } - - /** - * Call a tool on the MCP server - */ - async callTool(name: string, args?: Record): Promise { - if (!this.client || this.state !== 'ready') { - throw new Error('MCP client not ready') - } - - try { - const result = await this.client.request( - { - method: 'tools/call', - params: { name, arguments: args }, - }, - CallToolResultSchema, - ) - return result - } catch (err) { - this.addLog('error', `Error calling tool ${name}: ${err instanceof Error ? err.message : String(err)}`) - throw err - } - } - - /** - * Retry connection - */ - retry(): void { - if (this.state === 'failed') { - this.disconnect().then(() => this.connect()) - } - } - - /** - * Manually trigger authentication - */ - async authenticate(): Promise { - if (!this.authProvider) { - try { - // Discover OAuth metadata if we don't have it yet - this.addLog('info', 'Discovering OAuth metadata...') - this.metadata = await discoverOAuthMetadata(this.url) - this.addLog('debug', `OAuth metadata: ${this.metadata ? 'Found' : 'Not available'}`) - - if (!this.metadata) { - throw new Error('No OAuth metadata available') - } - - // Initialize the auth provider now that we have metadata - this.initAuthProvider() - } catch (err) { - this.addLog('error', `Failed to discover OAuth metadata: ${err instanceof Error ? err.message : String(err)}`) - return undefined - } - } - - try { - // If we don't have an auth URL yet with state param, start a new flow - if (!this.authUrlRef || !this.authUrlRef.searchParams.get('state')) { - await this.startAuthFlow() - } - - if (!this.authUrlRef) { - throw new Error('Failed to create authorization URL') - } - - // The URL already has the state parameter from startAuthFlow - return this.authUrlRef.toString() - } catch (err) { - this.addLog('error', `Error preparing manual authentication: ${err instanceof Error ? err.message : String(err)}`) - return undefined - } - } - - /** - * Clear all localStorage items for this server - */ - clearStorage(): number { - if (!this.authProvider) { - this.addLog('warn', 'Cannot clear storage: auth provider not initialized') - return 0 - } - - // Use the provider's method to clear storage - const clearedCount = this.authProvider.clearStorage() - - // Clear auth-related state in the class - this.authUrlRef = undefined - this.setAuthUrl(undefined) - this.metadata = undefined - this.codeVerifier = undefined - - this.addLog('info', `Cleared ${clearedCount} storage items for server`) - - return clearedCount - } -} - -/** - * 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) - - // Use a ref to maintain a single instance of the McpClient - const clientRef = useRef(null) - const isInitialMount = useRef(true) - - // Initialize the client if it doesn't exist yet - const getClient = useCallback(() => { - if (!clientRef.current) { - clientRef.current = new McpClient( - options.url, - { - clientName: options.clientName || 'MCP React Client', - clientUri: options.clientUri || window.location.origin, - callbackUrl: options.callbackUrl || new URL('/oauth/callback', window.location.origin).toString(), - storageKeyPrefix: options.storageKeyPrefix || 'mcp:auth', - clientConfig: options.clientConfig || { - name: 'mcp-react-client', - version: '0.1.0', - }, - debug: options.debug || false, - autoRetry: options.autoRetry || false, - autoReconnect: options.autoReconnect || 3000, - popupFeatures: options.popupFeatures || 'width=600,height=700,resizable=yes,scrollbars=yes', - }, - { - onStateChange: setState, - onToolsChange: setTools, - onErrorChange: setError, - onLogChange: setLog, - onAuthUrlChange: setAuthUrl, - }, - ) - } - return clientRef.current - }, [ - options.url, - options.clientName, - options.clientUri, - options.callbackUrl, - options.storageKeyPrefix, - options.clientConfig, - options.debug, - options.autoRetry, - options.autoReconnect, - options.popupFeatures, - ]) - - // Connect on initial mount - useEffect(() => { - if (isInitialMount.current) { - isInitialMount.current = false - const client = getClient() - client.connect() - } - }, [getClient]) - - // Auto-retry on failure - useEffect(() => { - if (state === 'failed' && options.autoRetry) { - const delay = typeof options.autoRetry === 'number' ? options.autoRetry : 5000 - const timeoutId = setTimeout(() => { - const client = getClient() - client.retry() - }, delay) - - return () => { - clearTimeout(timeoutId) - } - } - }, [state, options.autoRetry, getClient]) - - // Set up message listener for auth callback - useEffect(() => { - const messageHandler = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return - - if (event.data && event.data.type === 'mcp_auth_callback') { - const client = getClient() - - // If code is provided, use it; otherwise, assume tokens are already in localStorage - if (event.data.code) { - client.handleAuthCompletion(event.data.code).catch((err) => { - console.error('Auth callback error:', err) - }) - } else { - // Tokens were already exchanged by the popup - client.handleAuthCompletion('TOKENS_ALREADY_EXCHANGED').catch((err) => { - console.error('Auth callback error:', err) - }) - } - } - } - - window.addEventListener('message', messageHandler) - return () => { - window.removeEventListener('message', messageHandler) - } - }, [getClient]) - - // Clean up on unmount - useEffect(() => { - return () => { - if (clientRef.current) { - clientRef.current.disconnect() - } - } - }, []) - - // Public methods - proxied to the client - const callTool = useCallback( - async (name: string, args?: Record) => { - const client = getClient() - return client.callTool(name, args) - }, - [getClient], - ) - - const retry = useCallback(() => { - const client = getClient() - client.retry() - }, [getClient]) - - const disconnect = useCallback(async () => { - const client = getClient() - await client.disconnect() - }, [getClient]) - - const authenticate = useCallback(async (): Promise => { - const client = getClient() - return client.authenticate() - }, [getClient]) - - const clearStorage = useCallback(() => { - const client = getClient() - client.clearStorage() - }, [getClient]) - - return { - state, - tools, - error, - log, - authUrl, - callTool, - retry, - disconnect, - authenticate, - clearStorage, - } -} - -/** - * 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 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', - // Don't send the code back since we've already done the token exchange - // This signals to the main window that tokens are already in localStorage - }, - 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 } - } -}