From d41e92812f033dc381cf3971211cf497a0fcdce2 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 14:32:41 +1100 Subject: [PATCH 01/18] moving things around now that /react is moving to use-mcp --- package.json | 27 +- src/{cli => }/client.ts | 10 +- src/{cli => }/proxy.ts | 4 +- src/react/index.ts | 1243 --------------------------------------- src/{cli => }/shared.ts | 2 +- 5 files changed, 22 insertions(+), 1264 deletions(-) rename src/{cli => }/client.ts (96%) rename src/{cli => }/proxy.ts (96%) delete mode 100644 src/react/index.ts rename src/{cli => }/shared.ts (99%) diff --git a/package.json b/package.json index e9f0be6..a7c0e02 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,25 @@ { "name": "mcp-remote", "version": "0.0.10", + "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/remote-mcp", "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/cli/proxy.js" }, "scripts": { "dev": "tsup --watch", @@ -39,9 +43,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/client.ts b/src/client.ts similarity index 96% rename from src/cli/client.ts rename to src/client.ts index f79e917..192c178 100644 --- a/src/cli/client.ts +++ b/src/client.ts @@ -14,7 +14,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared.js' +import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared' /** * Main function to run the client @@ -33,13 +33,11 @@ async function runClient(serverUrl: string, callbackPort: number) { // Create the client const client = new Client( { - name: 'mcp-cli', - version: '0.1.0', + name: 'mcp-remote', + version: require('../package.json').version, }, { - capabilities: { - sampling: {}, - }, + capabilities: {}, }, ) diff --git a/src/cli/proxy.ts b/src/proxy.ts similarity index 96% rename from src/cli/proxy.ts rename to src/proxy.ts index d705b35..d2b7e4a 100644 --- a/src/cli/proxy.ts +++ b/src/proxy.ts @@ -11,8 +11,8 @@ 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 { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared' +import { connectToRemoteServer, mcpProxy } from './lib/utils' /** * Main function to run the proxy 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 } - } -} diff --git a/src/cli/shared.ts b/src/shared.ts similarity index 99% rename from src/cli/shared.ts rename to src/shared.ts index b146abd..91a8e80 100644 --- a/src/cli/shared.ts +++ b/src/shared.ts @@ -18,7 +18,7 @@ import { OAuthTokens, OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' -import { OAuthCallbackServerOptions, OAuthProviderOptions } from '../lib/types.js' +import { OAuthCallbackServerOptions, OAuthProviderOptions } from './lib/types' /** * Implements the OAuthClientProvider interface for Node.js environments. From 1dd99edc0d2a32f432239d228ab6c814988addf5 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 14:38:15 +1100 Subject: [PATCH 02/18] breaking shared apart --- src/client.ts | 3 +- .../node-oauth-client-provider.ts} | 143 +----------------- src/lib/utils.ts | 131 ++++++++++++++++ src/proxy.ts | 4 +- 4 files changed, 139 insertions(+), 142 deletions(-) rename src/{shared.ts => lib/node-oauth-client-provider.ts} (60%) diff --git a/src/client.ts b/src/client.ts index 192c178..48d6ea4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,7 +14,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' import { ListResourcesResultSchema, ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared' +import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' +import { parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers } from './lib/utils' /** * Main function to run the client diff --git a/src/shared.ts b/src/lib/node-oauth-client-provider.ts similarity index 60% rename from src/shared.ts rename to src/lib/node-oauth-client-provider.ts index 91a8e80..920b8e5 100644 --- a/src/shared.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -1,15 +1,8 @@ -/** - * 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 crypto from 'crypto' import path from 'path' import os from 'os' -import crypto from 'crypto' -import net from 'net' +import fs from 'fs/promises' +import open from 'open' import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' import { OAuthClientInformation, @@ -18,7 +11,7 @@ import { OAuthTokens, OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' -import { OAuthCallbackServerOptions, OAuthProviderOptions } from './lib/types' +import type { OAuthProviderOptions } from './types' /** * Implements the OAuthClientProvider interface for Node.js environments. @@ -204,131 +197,3 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { 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/lib/utils.ts b/src/lib/utils.ts index 2b09f57..33e8685 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,9 @@ 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' const pid = process.pid @@ -99,3 +102,131 @@ export async function connectToRemoteServer( } } } + +/** + * 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/proxy.ts b/src/proxy.ts index d2b7e4a..23eaafe 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -11,8 +11,8 @@ import { EventEmitter } from 'events' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { NodeOAuthClientProvider, setupOAuthCallbackServer, parseCommandLineArgs, setupSignalHandlers } from './shared' -import { connectToRemoteServer, mcpProxy } from './lib/utils' +import { connectToRemoteServer, mcpProxy, parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers } from './lib/utils' +import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' /** * Main function to run the proxy From a32681e154d7d443ed6a154edffdea8d0ab217b6 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 14:42:19 +1100 Subject: [PATCH 03/18] extracted the config directory access --- src/lib/mcp-auth-config.ts | 146 ++++++++++++++++++++++++++ src/lib/node-oauth-client-provider.ts | 121 ++++----------------- 2 files changed, 169 insertions(+), 98 deletions(-) create mode 100644 src/lib/mcp-auth-config.ts diff --git a/src/lib/mcp-auth-config.ts b/src/lib/mcp-auth-config.ts new file mode 100644 index 0000000..8f06d41 --- /dev/null +++ b/src/lib/mcp-auth-config.ts @@ -0,0 +1,146 @@ +import crypto from 'crypto' +import path from 'path' +import os from 'os' +import fs from 'fs/promises' + +/** + * 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. + */ + +/** + * Gets the configuration directory path + * @returns The path to the configuration directory + */ +export function getConfigDir(): string { + return process.env.MCP_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.mcp-auth') +} + +/** + * Ensures the configuration directory exists + */ +export async function ensureConfigDir(): Promise { + try { + const configDir = getConfigDir() + await fs.mkdir(configDir, { recursive: true }) + } catch (error) { + console.error('Error creating config directory:', error) + throw error + } +} + +/** + * 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') +} + +/** + * 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 + * @returns The parsed file content or undefined if the file doesn't exist + */ +export async function readJsonFile( + serverUrlHash: string, + filename: string, + schema: any +): Promise { + try { + await ensureConfigDir() + const configDir = getConfigDir() + const filePath = path.join(configDir, `${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 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 configDir = getConfigDir() + const filePath = path.join(configDir, `${serverUrlHash}_${filename}`) + await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8') + } catch (error) { + console.error(`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 + * @returns The file content as a string + */ +export async function readTextFile( + serverUrlHash: string, + filename: string, + errorMessage?: string +): Promise { + try { + await ensureConfigDir() + const configDir = getConfigDir() + const filePath = path.join(configDir, `${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 configDir = getConfigDir() + const filePath = path.join(configDir, `${serverUrlHash}_${filename}`) + await fs.writeFile(filePath, text, 'utf-8') + } catch (error) { + console.error(`Error writing ${filename}:`, error) + throw error + } +} \ No newline at end of file diff --git a/src/lib/node-oauth-client-provider.ts b/src/lib/node-oauth-client-provider.ts index 920b8e5..c97743e 100644 --- a/src/lib/node-oauth-client-provider.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -1,7 +1,3 @@ -import crypto from 'crypto' -import path from 'path' -import os from 'os' -import fs from 'fs/promises' import open from 'open' import { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' import { @@ -12,13 +8,19 @@ import { OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' import type { OAuthProviderOptions } from './types' +import { + getServerUrlHash, + readJsonFile, + writeJsonFile, + readTextFile, + writeTextFile, +} from './mcp-auth-config' /** * 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 @@ -29,8 +31,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @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.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' @@ -51,96 +52,16 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { } } - /** - * 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) + return readJsonFile( + this.serverUrlHash, + 'client_info.json', + OAuthClientInformationSchema + ) } /** @@ -148,7 +69,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @param clientInformation The client information to save */ async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise { - await this.writeFile('client_info.json', clientInformation) + await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation) } /** @@ -156,7 +77,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @returns The OAuth tokens or undefined */ async tokens(): Promise { - return this.readFile('tokens.json', OAuthTokensSchema) + return readJsonFile(this.serverUrlHash, 'tokens.json', OAuthTokensSchema) } /** @@ -164,7 +85,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @param tokens The tokens to save */ async saveTokens(tokens: OAuthTokens): Promise { - await this.writeFile('tokens.json', tokens) + await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens) } /** @@ -186,7 +107,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @param codeVerifier The code verifier to save */ async saveCodeVerifier(codeVerifier: string): Promise { - await this.writeTextFile('code_verifier.txt', codeVerifier) + await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier) } /** @@ -194,6 +115,10 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @returns The code verifier */ async codeVerifier(): Promise { - return await this.readTextFile('code_verifier.txt') + return await readTextFile( + this.serverUrlHash, + 'code_verifier.txt', + 'No code verifier saved for session' + ) } -} +} \ No newline at end of file From 027007030e6400beadbdeab94e4f17243fd2ff29 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 15:02:27 +1100 Subject: [PATCH 04/18] adding the --clean flag --- src/client.ts | 14 +++-- src/lib/mcp-auth-config.ts | 82 +++++++++++++++++++++++---- src/lib/node-oauth-client-provider.ts | 21 ++++++- src/lib/types.ts | 2 + src/lib/utils.ts | 17 +++++- src/proxy.ts | 14 +++-- 6 files changed, 125 insertions(+), 25 deletions(-) diff --git a/src/client.ts b/src/client.ts index 48d6ea4..451325c 100644 --- a/src/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. */ @@ -20,7 +23,7 @@ import { parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers } f /** * 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() @@ -29,6 +32,7 @@ async function runClient(serverUrl: string, callbackPort: number) { serverUrl, callbackPort, clientName: 'MCP CLI Client', + clean, }) // Create the client @@ -147,9 +151,9 @@ async function runClient(serverUrl: string, callbackPort: number) { } // 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/mcp-auth-config.ts b/src/lib/mcp-auth-config.ts index 8f06d41..f52fc64 100644 --- a/src/lib/mcp-auth-config.ts +++ b/src/lib/mcp-auth-config.ts @@ -23,6 +23,26 @@ import fs from 'fs/promises' * 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', +]; + +/** + * Deletes all known configuration files for a specific server + * @param serverUrlHash The hash of the server URL + */ +export async function cleanServerConfig(serverUrlHash: string): Promise { + console.error(`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 @@ -53,22 +73,58 @@ export function getServerUrlHash(serverUrl: string): string { return crypto.createHash('md5').update(serverUrl).digest('hex') } +/** + * 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') { + console.error(`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 + schema: any, + clean: boolean = false ): Promise { try { await ensureConfigDir() - const configDir = getConfigDir() - const filePath = path.join(configDir, `${serverUrlHash}_${filename}`) + + // 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') return await schema.parseAsync(JSON.parse(content)) } catch (error) { @@ -92,8 +148,7 @@ export async function writeJsonFile( ): Promise { try { await ensureConfigDir() - const configDir = getConfigDir() - const filePath = path.join(configDir, `${serverUrlHash}_${filename}`) + const filePath = getConfigFilePath(serverUrlHash, filename) await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8') } catch (error) { console.error(`Error writing ${filename}:`, error) @@ -106,17 +161,25 @@ export async function writeJsonFile( * @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 + errorMessage?: string, + clean: boolean = false ): Promise { try { await ensureConfigDir() - const configDir = getConfigDir() - const filePath = path.join(configDir, `${serverUrlHash}_${filename}`) + + // 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}`) @@ -136,8 +199,7 @@ export async function writeTextFile( ): Promise { try { await ensureConfigDir() - const configDir = getConfigDir() - const filePath = path.join(configDir, `${serverUrlHash}_${filename}`) + const filePath = getConfigFilePath(serverUrlHash, filename) await fs.writeFile(filePath, text, 'utf-8') } catch (error) { console.error(`Error writing ${filename}:`, error) diff --git a/src/lib/node-oauth-client-provider.ts b/src/lib/node-oauth-client-provider.ts index c97743e..ed6caed 100644 --- a/src/lib/node-oauth-client-provider.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -14,6 +14,7 @@ import { writeJsonFile, readTextFile, writeTextFile, + cleanServerConfig, } from './mcp-auth-config' /** @@ -35,6 +36,13 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { 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 => { + console.error('Error cleaning server config:', err) + }) + } } get redirectUrl(): string { @@ -60,7 +68,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { return readJsonFile( this.serverUrlHash, 'client_info.json', - OAuthClientInformationSchema + OAuthClientInformationSchema, + this.options.clean ) } @@ -77,7 +86,12 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @returns The OAuth tokens or undefined */ async tokens(): Promise { - return readJsonFile(this.serverUrlHash, 'tokens.json', OAuthTokensSchema) + return readJsonFile( + this.serverUrlHash, + 'tokens.json', + OAuthTokensSchema, + this.options.clean + ) } /** @@ -118,7 +132,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { return await readTextFile( this.serverUrlHash, 'code_verifier.txt', - 'No code verifier saved for session' + 'No code verifier saved for session', + this.options.clean ) } } \ No newline at end of file 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 33e8685..e9b4bf3 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -185,9 +185,18 @@ export async function findAvailablePort(preferredPort?: number): Promise * @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 + * @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 @@ -212,8 +221,12 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, } else { console.error(`Using automatically selected callback port: ${callbackPort}`) } + + if (clean) { + console.error('Clean mode enabled: config files will be reset before reading') + } - return { serverUrl, callbackPort } + return { serverUrl, callbackPort, clean } } /** diff --git a/src/proxy.ts b/src/proxy.ts index 23eaafe..dff2e9e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,7 +4,10 @@ * 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. */ @@ -17,7 +20,7 @@ import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' /** * 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() @@ -26,6 +29,7 @@ async function runProxy(serverUrl: string, callbackPort: number) { serverUrl, callbackPort, clientName: 'MCP CLI Proxy', + clean, }) // Create the STDIO transport for local connections @@ -91,9 +95,9 @@ 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) From bcdf520af5dd850c17169fde9bf98c9362517fa8 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 15:30:54 +1100 Subject: [PATCH 05/18] updated readme --- README.md | 118 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 70 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 3775d21..5d42804 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,74 @@ 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" + ] +``` + +* To ensure that no + +### 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 +118,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 + +### Wipe 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 From d10063463da412967782779677cc34cb9bb6c63a Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 15:35:52 +1100 Subject: [PATCH 06/18] 0.0.11 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a7c0e02..0ff649e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-remote", - "version": "0.0.10", + "version": "0.0.11", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "keywords": [ "mcp", From eaed1f31e85856912cdfb18bd27bf7b96271b1ad Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 15:41:59 +1100 Subject: [PATCH 07/18] exposing both CLI tools --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0ff649e..a605ed2 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ ], "main": "dist/index.js", "bin": { - "mcp-remote": "dist/cli/proxy.js" + "mcp-remote": "dist/proxy.js", + "mcp-remote-client": "dist/client.js" }, "scripts": { "dev": "tsup --watch", From 3f1664cdbad25013b053c17d18fb4818af66172a Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 15:42:02 +1100 Subject: [PATCH 08/18] 0.0.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a605ed2..bb808ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-remote", - "version": "0.0.11", + "version": "0.0.12", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "keywords": [ "mcp", From a97fd9e5c635f3e4726497df5558eb7d23e3b76d Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 16:10:12 +1100 Subject: [PATCH 09/18] added instructions for running the client --- README.md | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5d42804..4d373c2 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,6 @@ All the most popular MCP clients (Claude Desktop, Cursor & Windsurf) use the fol ] ``` -* To ensure that no - ### Claude Desktop [Official Docs](https://modelcontextprotocol.io/quickstart/user) @@ -120,7 +118,7 @@ Know of more resources you'd like to share? Please add them to this Readme and s ## Troubleshooting -### Wipe your `~/.mcp-auth` directory +### 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: @@ -166,16 +164,17 @@ this might look like: ### Check the logs -[Follow Claude Desktop logs in real-time](https://modelcontextprotocol.io/docs/tools/debugging#debugging-in-claude-desktop) +* [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` -MacOS / Linux: +### "Client" mode -`tail -n 20 -F ~/Library/Logs/Claude/mcp*.log` +Run the following on the command line (not from an MCP server): -For bash on WSL: +```shell +npx -p mcp-remote@latest mcp-remote-client https://remote.mcp.server/sse +``` -`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` +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. From 1382827ebd03297d3f3fd0a4beffce18cdcd0029 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 16:21:34 +1100 Subject: [PATCH 10/18] Versioning storage based on mcp-auth version number to let us iterate on storage format --- src/client.ts | 4 +-- src/lib/mcp-auth-config.ts | 51 ++++++++++++++++---------------------- src/lib/utils.ts | 8 +++--- 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/src/client.ts b/src/client.ts index 451325c..c76a538 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,7 +18,7 @@ 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 } from './lib/node-oauth-client-provider' -import { parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers } from './lib/utils' +import { parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers, MCP_REMOTE_VERSION } from './lib/utils' /** * Main function to run the client @@ -39,7 +39,7 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean const client = new Client( { name: 'mcp-remote', - version: require('../package.json').version, + version: MCP_REMOTE_VERSION, }, { capabilities: {}, diff --git a/src/lib/mcp-auth-config.ts b/src/lib/mcp-auth-config.ts index f52fc64..e35badc 100644 --- a/src/lib/mcp-auth-config.ts +++ b/src/lib/mcp-auth-config.ts @@ -2,16 +2,17 @@ import crypto from 'crypto' import path from 'path' import os from 'os' import fs from 'fs/promises' +import { 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 @@ -19,18 +20,14 @@ import fs from 'fs/promises' * - 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', -]; +export const knownConfigFiles = ['client_info.json', 'tokens.json', 'code_verifier.txt'] /** * Deletes all known configuration files for a specific server @@ -48,7 +45,9 @@ export async function cleanServerConfig(serverUrlHash: string): Promise { * @returns The path to the configuration directory */ export function getConfigDir(): string { - return process.env.MCP_REMOTE_CONFIG_DIR || path.join(os.homedir(), '.mcp-auth') + 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}`) } /** @@ -110,20 +109,20 @@ export async function deleteConfigFile(serverUrlHash: string, filename: string): * @returns The parsed file content or undefined if the file doesn't exist */ export async function readJsonFile( - serverUrlHash: string, - filename: string, + serverUrlHash: string, + filename: string, schema: any, - clean: boolean = false + 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') return await schema.parseAsync(JSON.parse(content)) @@ -141,11 +140,7 @@ export async function readJsonFile( * @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 { +export async function writeJsonFile(serverUrlHash: string, filename: string, data: any): Promise { try { await ensureConfigDir() const filePath = getConfigFilePath(serverUrlHash, filename) @@ -165,20 +160,20 @@ export async function writeJsonFile( * @returns The file content as a string */ export async function readTextFile( - serverUrlHash: string, + serverUrlHash: string, filename: string, errorMessage?: string, - clean: boolean = false + 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) { @@ -192,11 +187,7 @@ export async function readTextFile( * @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 { +export async function writeTextFile(serverUrlHash: string, filename: string, text: string): Promise { try { await ensureConfigDir() const filePath = getConfigFilePath(serverUrlHash, filename) @@ -205,4 +196,4 @@ export async function writeTextFile( console.error(`Error writing ${filename}:`, error) throw error } -} \ No newline at end of file +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e9b4bf3..3cbb1f6 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -191,12 +191,12 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, // 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 @@ -221,7 +221,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, } else { console.error(`Using automatically selected callback port: ${callbackPort}`) } - + if (clean) { console.error('Clean mode enabled: config files will be reset before reading') } @@ -243,3 +243,5 @@ export function setupSignalHandlers(cleanup: () => Promise) { // Keep the process alive process.stdin.resume() } + +export const MCP_REMOTE_VERSION = require('../../package.json').version From eee7b1b8d532be0d257eb95f2e7b01b37ebbccaf Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 16:29:45 +1100 Subject: [PATCH 11/18] Added process.pid to all logs so we can deal with claude code forking mulitple instances --- src/lib/mcp-auth-config.ts | 12 +++---- src/lib/node-oauth-client-provider.ts | 45 +++++++-------------------- src/lib/utils.ts | 40 +++++++++++++----------- src/proxy.ts | 14 ++++----- 4 files changed, 47 insertions(+), 64 deletions(-) diff --git a/src/lib/mcp-auth-config.ts b/src/lib/mcp-auth-config.ts index e35badc..a71782a 100644 --- a/src/lib/mcp-auth-config.ts +++ b/src/lib/mcp-auth-config.ts @@ -2,7 +2,7 @@ import crypto from 'crypto' import path from 'path' import os from 'os' import fs from 'fs/promises' -import { MCP_REMOTE_VERSION } from './utils' +import { log, MCP_REMOTE_VERSION } from './utils' /** * MCP Remote Authentication Configuration @@ -34,7 +34,7 @@ export const knownConfigFiles = ['client_info.json', 'tokens.json', 'code_verifi * @param serverUrlHash The hash of the server URL */ export async function cleanServerConfig(serverUrlHash: string): Promise { - console.error(`Cleaning configuration files for server: ${serverUrlHash}`) + log(`Cleaning configuration files for server: ${serverUrlHash}`) for (const filename of knownConfigFiles) { await deleteConfigFile(serverUrlHash, filename) } @@ -58,7 +58,7 @@ export async function ensureConfigDir(): Promise { const configDir = getConfigDir() await fs.mkdir(configDir, { recursive: true }) } catch (error) { - console.error('Error creating config directory:', error) + log('Error creating config directory:', error) throw error } } @@ -95,7 +95,7 @@ export async function deleteConfigFile(serverUrlHash: string, filename: string): } catch (error) { // Ignore if file doesn't exist if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error(`Error deleting ${filename}:`, error) + log(`Error deleting ${filename}:`, error) } } } @@ -146,7 +146,7 @@ export async function writeJsonFile(serverUrlHash: string, filename: string, dat const filePath = getConfigFilePath(serverUrlHash, filename) await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8') } catch (error) { - console.error(`Error writing ${filename}:`, error) + log(`Error writing ${filename}:`, error) throw error } } @@ -193,7 +193,7 @@ export async function writeTextFile(serverUrlHash: string, filename: string, tex const filePath = getConfigFilePath(serverUrlHash, filename) await fs.writeFile(filePath, text, 'utf-8') } catch (error) { - console.error(`Error writing ${filename}:`, 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 index ed6caed..0a05d47 100644 --- a/src/lib/node-oauth-client-provider.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -8,14 +8,8 @@ import { OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' import type { OAuthProviderOptions } from './types' -import { - getServerUrlHash, - readJsonFile, - writeJsonFile, - readTextFile, - writeTextFile, - cleanServerConfig, -} from './mcp-auth-config' +import { getServerUrlHash, readJsonFile, writeJsonFile, readTextFile, writeTextFile, cleanServerConfig } from './mcp-auth-config' +import { log } from './utils' /** * Implements the OAuthClientProvider interface for Node.js environments. @@ -36,11 +30,11 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { 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 => { - console.error('Error cleaning server config:', err) + cleanServerConfig(this.serverUrlHash).catch((err) => { + log('Error cleaning server config:', err) }) } } @@ -65,12 +59,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @returns The client information or undefined */ async clientInformation(): Promise { - return readJsonFile( - this.serverUrlHash, - 'client_info.json', - OAuthClientInformationSchema, - this.options.clean - ) + return readJsonFile(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema, this.options.clean) } /** @@ -86,12 +75,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @returns The OAuth tokens or undefined */ async tokens(): Promise { - return readJsonFile( - this.serverUrlHash, - 'tokens.json', - OAuthTokensSchema, - this.options.clean - ) + return readJsonFile(this.serverUrlHash, 'tokens.json', OAuthTokensSchema, this.options.clean) } /** @@ -107,12 +91,12 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @param authorizationUrl The URL to redirect to */ async redirectToAuthorization(authorizationUrl: URL): Promise { - console.error(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`) + log(`\nPlease authorize this client by visiting:\n${authorizationUrl.toString()}\n`) try { await open(authorizationUrl.toString()) - console.error('Browser opened automatically.') + log('Browser opened automatically.') } catch (error) { - console.error('Could not open browser automatically. Please copy and paste the URL above into your browser.') + log('Could not open browser automatically. Please copy and paste the URL above into your browser.') } } @@ -129,11 +113,6 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @returns The code verifier */ async codeVerifier(): Promise { - return await readTextFile( - this.serverUrlHash, - 'code_verifier.txt', - 'No code verifier saved for session', - this.options.clean - ) + return await readTextFile(this.serverUrlHash, 'code_verifier.txt', 'No code verifier saved for session', this.options.clean) } -} \ No newline at end of file +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3cbb1f6..08a6f5d 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -6,6 +6,10 @@ import express from 'express' import net from 'net' 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 @@ -17,13 +21,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) } @@ -48,11 +52,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) } } @@ -68,36 +72,36 @@ export async function connectToRemoteServer( authProvider: OAuthClientProvider, waitForAuthCode: () => Promise, ): 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...') + 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 } } @@ -127,7 +131,7 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { }) const server = app.listen(options.port, () => { - console.error(`OAuth callback server running at http://127.0.0.1:${options.port}`) + log(`OAuth callback server running at http://127.0.0.1:${options.port}`) }) /** @@ -201,7 +205,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, const specifiedPort = args[1] ? parseInt(args[1]) : undefined if (!serverUrl) { - console.error(usage) + log(usage) process.exit(1) } @@ -209,7 +213,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, const isLocalhost = (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && url.protocol === 'http:' if (!(url.protocol == 'https:' || isLocalhost)) { - console.error(usage) + log(usage) process.exit(1) } @@ -217,13 +221,13 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, const callbackPort = specifiedPort || (await findAvailablePort(defaultPort)) if (specifiedPort) { - console.error(`Using specified callback port: ${callbackPort}`) + log(`Using specified callback port: ${callbackPort}`) } else { - console.error(`Using automatically selected callback port: ${callbackPort}`) + log(`Using automatically selected callback port: ${callbackPort}`) } if (clean) { - console.error('Clean mode enabled: config files will be reset before reading') + log('Clean mode enabled: config files will be reset before reading') } return { serverUrl, callbackPort, clean } @@ -235,7 +239,7 @@ export async function parseCommandLineArgs(args: string[], defaultPort: number, */ export function setupSignalHandlers(cleanup: () => Promise) { process.on('SIGINT', async () => { - console.error('\nShutting down...') + log('\nShutting down...') await cleanup() process.exit(0) }) diff --git a/src/proxy.ts b/src/proxy.ts index dff2e9e..b549018 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -14,7 +14,7 @@ import { EventEmitter } from 'events' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { connectToRemoteServer, mcpProxy, parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers } from './lib/utils' +import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers } from './lib/utils' import { NodeOAuthClientProvider } from './lib/node-oauth-client-provider' /** @@ -54,9 +54,9 @@ async function runProxy(serverUrl: string, callbackPort: number, clean: boolean // 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 () => { @@ -66,9 +66,9 @@ async function runProxy(serverUrl: string, callbackPort: number, clean: boolean } 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: @@ -100,6 +100,6 @@ parseCommandLineArgs(process.argv.slice(2), 3334, 'Usage: npx tsx proxy.ts [--cl return runProxy(serverUrl, callbackPort, clean) }) .catch((error) => { - console.error('Fatal error:', error) + log('Fatal error:', error) process.exit(1) }) From e793ed5b04a8df6111644df63053cb0669d03408 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 16:31:41 +1100 Subject: [PATCH 12/18] 0.0.13 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index bb808ae..8d27591 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-remote", - "version": "0.0.12", + "version": "0.0.13", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "keywords": [ "mcp", From 412b5d9486b54e2383f67bbbacc82f08735afad3 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 19:38:52 +1100 Subject: [PATCH 13/18] most of the implementation looking ok but sharing the token the wrong way --- src/client.ts | 71 +++++----- src/lib/coordination.ts | 181 ++++++++++++++++++++++++++ src/lib/mcp-auth-config.ts | 66 ++++++++-- src/lib/node-oauth-client-provider.ts | 4 +- src/lib/utils.ts | 86 +++++++++++- src/proxy.ts | 18 +-- 6 files changed, 364 insertions(+), 62 deletions(-) create mode 100644 src/lib/coordination.ts diff --git a/src/client.ts b/src/client.ts index c76a538..80b2dd4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,7 +18,8 @@ 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 } from './lib/node-oauth-client-provider' -import { parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers, MCP_REMOTE_VERSION } from './lib/utils' +import { parseCommandLineArgs, setupSignalHandlers, log, MCP_REMOTE_VERSION, getServerUrlHash } from './lib/utils' +import { coordinateAuth } from './lib/coordination' /** * Main function to run the client @@ -27,6 +28,12 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean // 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, @@ -35,6 +42,11 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean clean, }) + // If we got auth from another instance, pre-populate with the received code + if (skipBrowserAuth) { + log('Using auth code from another instance') + } + // Create the client const client = new Client( { @@ -53,15 +65,15 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean // 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 @@ -69,16 +81,9 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean 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() } @@ -86,44 +91,44 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean // 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) } @@ -131,23 +136,23 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean 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 diff --git a/src/lib/coordination.ts b/src/lib/coordination.ts new file mode 100644 index 0000000..a0568d7 --- /dev/null +++ b/src/lib/coordination.ts @@ -0,0 +1,181 @@ +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 The auth code if successful, 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) { + const code = await response.text() + log(`Received code: ${code}`) + return code // Return the auth code + } else if (response.status === 202) { + // do nothing, loop + } 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; authCode?: string }> { + // 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 code = await waitForAuthentication(lockData.port) + if (code) { + log('Authentication completed by another instance') + + // Setup a dummy server and return a pre-resolved promise for the auth code + const dummyServer = express().listen(0) // Listen on any available port + const dummyWaitForAuthCode = () => Promise.resolve(code) + + return { + server: dummyServer, + waitForAuthCode: dummyWaitForAuthCode, + skipBrowserAuth: true, + authCode: code, + } + } 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 index a71782a..7782096 100644 --- a/src/lib/mcp-auth-config.ts +++ b/src/lib/mcp-auth-config.ts @@ -1,4 +1,3 @@ -import crypto from 'crypto' import path from 'path' import os from 'os' import fs from 'fs/promises' @@ -27,7 +26,61 @@ import { log, MCP_REMOTE_VERSION } from './utils' /** * Known configuration file names that might need to be cleaned */ -export const knownConfigFiles = ['client_info.json', 'tokens.json', 'code_verifier.txt'] +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 @@ -63,15 +116,6 @@ export async function ensureConfigDir(): Promise { } } -/** - * 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') -} - /** * Gets the file path for a config file * @param serverUrlHash The hash of the server URL diff --git a/src/lib/node-oauth-client-provider.ts b/src/lib/node-oauth-client-provider.ts index 0a05d47..f8203fc 100644 --- a/src/lib/node-oauth-client-provider.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -8,8 +8,8 @@ import { OAuthTokensSchema, } from '@modelcontextprotocol/sdk/shared/auth.js' import type { OAuthProviderOptions } from './types' -import { getServerUrlHash, readJsonFile, writeJsonFile, readTextFile, writeTextFile, cleanServerConfig } from './mcp-auth-config' -import { log } from './utils' +import { readJsonFile, writeJsonFile, readTextFile, writeTextFile, cleanServerConfig } from './mcp-auth-config' +import { getServerUrlHash, log } from './utils' /** * Implements the OAuthClientProvider interface for Node.js environments. diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 08a6f5d..fa0360e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -4,6 +4,10 @@ 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[]) { @@ -65,12 +69,14 @@ 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 { log(`[${pid}] Connecting to remote server: ${serverUrl}`) const url = new URL(serverUrl) @@ -82,7 +88,11 @@ export async function connectToRemoteServer( return transport } catch (error) { if (error instanceof UnauthorizedError || (error instanceof Error && error.message.includes('Unauthorized'))) { - log('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() @@ -112,10 +122,56 @@ export async function connectToRemoteServer( * @param options The server options * @returns An object with the server, authCode, and waitForAuthCode function */ -export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { +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 + log('Auth already completed, returning immediately') + res.status(200).send(authCode) + 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((code) => { + clearTimeout(longPollTimeout) + if (!res.headersSent) { + log('Auth completed during long poll, responding with 200') + res.status(200).send(code) + } + }) + .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) { @@ -124,6 +180,9 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { } 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 @@ -134,10 +193,6 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { log(`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) { @@ -151,6 +206,16 @@ export function setupOAuthCallbackServer(options: OAuthCallbackServerOptions) { }) } + 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 } } @@ -248,4 +313,11 @@ export function setupSignalHandlers(cleanup: () => Promise) { process.stdin.resume() } -export const MCP_REMOTE_VERSION = require('../../package.json').version +/** + * 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/proxy.ts b/src/proxy.ts index b549018..d578abd 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -14,8 +14,9 @@ import { EventEmitter } from 'events' import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' -import { connectToRemoteServer, log, mcpProxy, parseCommandLineArgs, setupOAuthCallbackServer, setupSignalHandlers } from './lib/utils' +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 @@ -24,6 +25,12 @@ async function runProxy(serverUrl: string, callbackPort: number, clean: boolean // 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, @@ -35,16 +42,9 @@ async function runProxy(serverUrl: string, callbackPort: number, clean: boolean // 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({ From 9fbba0509c2efb9987eb39c9525771fc7b96bf49 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 21:16:21 +1100 Subject: [PATCH 14/18] Changing to returning a boolean, but tokens still not being loaded --- src/client.ts | 4 ++-- src/lib/coordination.ts | 33 ++++++++++++++++++++------------- src/lib/utils.ts | 11 ++++++----- src/proxy.ts | 5 +++++ 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/client.ts b/src/client.ts index 80b2dd4..7cf4db0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -42,9 +42,9 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean clean, }) - // If we got auth from another instance, pre-populate with the received code + // If auth was completed by another instance, just log that we'll use the auth from disk if (skipBrowserAuth) { - log('Using auth code from another instance') + log('Authentication was completed by another instance - will use tokens from disk') } // Create the client diff --git a/src/lib/coordination.ts b/src/lib/coordination.ts index a0568d7..c173e7a 100644 --- a/src/lib/coordination.ts +++ b/src/lib/coordination.ts @@ -58,9 +58,9 @@ export async function isLockValid(lockData: LockfileData): Promise { /** * Waits for authentication from another server instance * @param port The port to connect to - * @returns The auth code if successful, false otherwise + * @returns True if authentication completed successfully, false otherwise */ -export async function waitForAuthentication(port: number): Promise { +export async function waitForAuthentication(port: number): Promise { log(`Waiting for authentication from the server on port ${port}...`) try { @@ -70,11 +70,13 @@ export async function waitForAuthentication(port: number): Promise setTimeout(resolve, 1000)) } else { log(`Unexpected response status: ${response.status}`) return false @@ -97,7 +99,7 @@ export async function coordinateAuth( serverUrlHash: string, callbackPort: number, events: EventEmitter, -): Promise<{ server: Server; waitForAuthCode: () => Promise; skipBrowserAuth: boolean; authCode?: string }> { +): Promise<{ server: Server; waitForAuthCode: () => Promise; skipBrowserAuth: boolean }> { // Check for a lockfile const lockData = await checkLockfile(serverUrlHash) @@ -107,19 +109,24 @@ export async function coordinateAuth( try { // Try to wait for the authentication to complete - const code = await waitForAuthentication(lockData.port) - if (code) { + const authCompleted = await waitForAuthentication(lockData.port) + if (authCompleted) { log('Authentication completed by another instance') - // Setup a dummy server and return a pre-resolved promise for the auth code + // Setup a dummy server - the client will use tokens directly from disk const dummyServer = express().listen(0) // Listen on any available port - const dummyWaitForAuthCode = () => Promise.resolve(code) + + // 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, - authCode: code, } } else { log('Taking over authentication process...') @@ -176,6 +183,6 @@ export async function coordinateAuth( return { server, waitForAuthCode, - skipBrowserAuth: false, + skipBrowserAuth: false } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index fa0360e..a37fe61 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -135,9 +135,10 @@ export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServe // Long-polling endpoint app.get('/wait-for-auth', (req, res) => { if (authCode) { - // Auth already completed - log('Auth already completed, returning immediately') - res.status(200).send(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 } @@ -155,11 +156,11 @@ export function setupOAuthCallbackServerWithLongPoll(options: OAuthCallbackServe // If auth completes while we're waiting, send the response immediately authCompletedPromise - .then((code) => { + .then(() => { clearTimeout(longPollTimeout) if (!res.headersSent) { log('Auth completed during long poll, responding with 200') - res.status(200).send(code) + res.status(200).send('Authentication completed') } }) .catch(() => { diff --git a/src/proxy.ts b/src/proxy.ts index d578abd..cbaaf5d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -38,6 +38,11 @@ async function runProxy(serverUrl: string, callbackPort: number, clean: boolean 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') + } // Create the STDIO transport for local connections const localTransport = new StdioServerTransport() From d5f5b3178640db9de934e0a4751171a68d98398b Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 22:28:10 +1100 Subject: [PATCH 15/18] Added a wait for the token exchange --- src/client.ts | 5 ++++- src/lib/mcp-auth-config.ts | 6 +++++- src/lib/node-oauth-client-provider.ts | 7 +++++++ src/proxy.ts | 5 ++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index 7cf4db0..5b908c3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -44,7 +44,10 @@ async function runClient(serverUrl: string, callbackPort: number, clean: boolean // 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') + 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 diff --git a/src/lib/mcp-auth-config.ts b/src/lib/mcp-auth-config.ts index 7782096..fce4219 100644 --- a/src/lib/mcp-auth-config.ts +++ b/src/lib/mcp-auth-config.ts @@ -169,11 +169,15 @@ export async function readJsonFile( const filePath = getConfigFilePath(serverUrlHash, filename) const content = await fs.readFile(filePath, 'utf-8') - return await schema.parseAsync(JSON.parse(content)) + 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 } } diff --git a/src/lib/node-oauth-client-provider.ts b/src/lib/node-oauth-client-provider.ts index f8203fc..c02ce97 100644 --- a/src/lib/node-oauth-client-provider.ts +++ b/src/lib/node-oauth-client-provider.ts @@ -59,6 +59,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @returns The client information or undefined */ async clientInformation(): Promise { + // log('Reading client info') return readJsonFile(this.serverUrlHash, 'client_info.json', OAuthClientInformationSchema, this.options.clean) } @@ -67,6 +68,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @param clientInformation The client information to save */ async saveClientInformation(clientInformation: OAuthClientInformationFull): Promise { + // log('Saving client info') await writeJsonFile(this.serverUrlHash, 'client_info.json', clientInformation) } @@ -75,6 +77,8 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @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) } @@ -83,6 +87,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @param tokens The tokens to save */ async saveTokens(tokens: OAuthTokens): Promise { + // log('Saving tokens') await writeJsonFile(this.serverUrlHash, 'tokens.json', tokens) } @@ -105,6 +110,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @param codeVerifier The code verifier to save */ async saveCodeVerifier(codeVerifier: string): Promise { + // log('Saving code verifier') await writeTextFile(this.serverUrlHash, 'code_verifier.txt', codeVerifier) } @@ -113,6 +119,7 @@ export class NodeOAuthClientProvider implements OAuthClientProvider { * @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/proxy.ts b/src/proxy.ts index cbaaf5d..4c8d75c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -38,10 +38,13 @@ async function runProxy(serverUrl: string, callbackPort: number, clean: boolean 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 From 03e56ea31b39135363063acc94da88a8cb3c4768 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 22:28:18 +1100 Subject: [PATCH 16/18] 0.0.14 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8d27591..93d6b36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-remote", - "version": "0.0.13", + "version": "0.0.14", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "keywords": [ "mcp", From 7eac41c561f0b9238ec62ed15f7bfa2c3264ed45 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 22:30:04 +1100 Subject: [PATCH 17/18] fixing github url --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 93d6b36..8a25440 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "oauth" ], "author": "Glen Maddern ", - "repository": "https://github.com/geelen/remote-mcp", + "repository": "https://github.com/geelen/mcp-remote", "type": "module", "files": [ "dist", From bd3610d87b268b91a05dc51d37e5880022be6260 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Mon, 31 Mar 2025 22:30:18 +1100 Subject: [PATCH 18/18] 0.0.15 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8a25440..b1edf20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mcp-remote", - "version": "0.0.14", + "version": "0.0.15", "description": "Remote proxy for Model Context Protocol, allowing local-only clients to connect to remote servers using oAuth", "keywords": [ "mcp",