From 55c0670654f3ce81af272e89e8480c6dd34639e4 Mon Sep 17 00:00:00 2001 From: Bruno Volpato Date: Tue, 20 Jan 2026 23:17:34 -0500 Subject: [PATCH 1/2] feat: add OAuth authentication support for HTTP MCP servers Add support for OAuth 2.0 authentication with PKCE for HTTP-based MCP servers that require authorization. Features: - OAuth 2.0 Authorization Code flow with PKCE (RFC 7636) - Dynamic client registration support - Persistent token storage in ~/.config/mcp/oauth_tokens.json - Automatic token refresh using refresh tokens - Local callback server for OAuth redirects (port 80 by default) - Browser-based authorization flow Usage: Add "oauth": true to HTTP server config: ```json { "mcpServers": { "my-server": { "url": "https://example.com/mcp", "oauth": true } } } ``` On first connection, the browser opens for authorization. Tokens are cached for subsequent connections. New files: - src/auth/token-storage.ts - Persistent OAuth token storage - src/auth/callback-server.ts - Local HTTP server for OAuth callbacks - src/auth/oauth-provider.ts - OAuthClientProvider implementation - src/auth/index.ts - Re-exports Modified files: - src/config.ts - Added OAuthConfig type and oauth field - src/client.ts - Added OAuth support for HTTP transports - src/index.ts - Updated help text with OAuth documentation --- src/auth/callback-server.ts | 327 ++++++++++++++++++++++++++++++++++++ src/auth/index.ts | 24 +++ src/auth/oauth-provider.ts | 296 ++++++++++++++++++++++++++++++++ src/auth/token-storage.ts | 124 ++++++++++++++ src/client.ts | 204 +++++++++++++++++++--- src/config.ts | 18 ++ src/index.ts | 14 ++ 7 files changed, 979 insertions(+), 28 deletions(-) create mode 100644 src/auth/callback-server.ts create mode 100644 src/auth/index.ts create mode 100644 src/auth/oauth-provider.ts create mode 100644 src/auth/token-storage.ts diff --git a/src/auth/callback-server.ts b/src/auth/callback-server.ts new file mode 100644 index 0000000..4dd1ecc --- /dev/null +++ b/src/auth/callback-server.ts @@ -0,0 +1,327 @@ +/** + * OAuth Callback Server - Local HTTP server for OAuth redirects + */ + +import { debug } from '../config.js'; + +// Bun Server type - we use ReturnType to infer from Bun.serve +type BunServer = ReturnType; + +/** + * OAuth callback data received from the authorization server + */ +export interface CallbackData { + code: string; + state: string; + error?: string; + errorDescription?: string; +} + +/** + * Pending callback promise + */ +interface PendingCallback { + resolve: (data: CallbackData) => void; + reject: (error: Error) => void; + timeout: ReturnType; +} + +/** + * HTML response for successful OAuth callback + */ +function successHtml(): string { + return ` + + + + Authorization Successful + + + +
+
+

Authorization Successful!

+

You can close this window and return to the terminal.

+
+ + +`; +} + +/** + * HTML response for OAuth error + */ +function errorHtml(error: string, description?: string): string { + return ` + + + + Authorization Failed + + + +
+
+

Authorization Failed

+

${description || error}

+

Error: ${error}

+
+ +`; +} + +/** + * OAuth callback server singleton + */ +class OAuthCallbackServer { + private server: BunServer | null = null; + private pendingCallbacks: Map = new Map(); + private static instance: OAuthCallbackServer | null = null; + + static getInstance(): OAuthCallbackServer { + if (!OAuthCallbackServer.instance) { + OAuthCallbackServer.instance = new OAuthCallbackServer(); + } + return OAuthCallbackServer.instance; + } + + /** + * Ensure the server is running + * @param preferredPort Optional preferred port. Tries 80 first for OAuth compatibility, then falls back. + */ + async ensureRunning(preferredPort?: number): Promise { + if (this.server) return; + + // Ports to try in order - port 80 for OAuth compatibility (http://localhost/callback) + // then fall back to common development ports + const portsToTry = preferredPort ? [preferredPort] : [80, 8080, 3000, 0]; // 0 = random port + + const createFetch = () => (req: Request) => { + const url = new URL(req.url); + + // Handle OAuth callback at /callback + if (url.pathname === '/callback') { + const code = url.searchParams.get('code'); + const state = url.searchParams.get('state'); + const error = url.searchParams.get('error'); + const errorDescription = url.searchParams.get('error_description'); + + // Handle error from OAuth provider + if (error) { + const callbackData: CallbackData = { + code: '', + state: state || '', + error, + errorDescription: errorDescription || undefined, + }; + + // Resolve pending callback with error + if (state) { + const pending = this.pendingCallbacks.get(state); + if (pending) { + clearTimeout(pending.timeout); + pending.resolve(callbackData); + this.pendingCallbacks.delete(state); + } + } + + return new Response(errorHtml(error, errorDescription || undefined), { + headers: { 'Content-Type': 'text/html' }, + }); + } + + // Validate required params + if (!code || !state) { + return new Response( + errorHtml('invalid_request', 'Missing code or state parameter'), + { + status: 400, + headers: { 'Content-Type': 'text/html' }, + }, + ); + } + + const callbackData: CallbackData = { + code, + state, + }; + + // Resolve pending callback + const pending = this.pendingCallbacks.get(state); + if (pending) { + clearTimeout(pending.timeout); + pending.resolve(callbackData); + this.pendingCallbacks.delete(state); + } + + return new Response(successHtml(), { + headers: { 'Content-Type': 'text/html' }, + }); + } + + // Health check endpoint + if (url.pathname === '/health') { + return new Response('OK', { status: 200 }); + } + + return new Response('Not Found', { status: 404 }); + }; + + // Try each port until one works + for (const port of portsToTry) { + try { + this.server = Bun.serve({ + port, + fetch: createFetch(), + }); + debug(`OAuth callback server started on port ${this.server.port}`); + return; + } catch (error) { + debug( + `Failed to start callback server on port ${port}: ${(error as Error).message}`, + ); + // Continue to next port + } + } + + throw new Error( + 'Failed to start OAuth callback server. Try running with sudo for port 80, or ensure ports 8080/3000 are available.', + ); + } + + /** + * Wait for OAuth callback with matching state + * @param state The state parameter to match + * @param timeout Timeout in milliseconds (default: 5 minutes) + */ + waitForCallback(state: string, timeout = 300000): Promise { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.pendingCallbacks.delete(state); + reject( + new Error(`OAuth callback timeout after ${timeout / 1000} seconds`), + ); + }, timeout); + + this.pendingCallbacks.set(state, { + resolve, + reject, + timeout: timeoutId, + }); + }); + } + + /** + * Cancel a pending callback + */ + cancelPending(state: string): void { + const pending = this.pendingCallbacks.get(state); + if (pending) { + clearTimeout(pending.timeout); + pending.reject(new Error('Callback cancelled')); + this.pendingCallbacks.delete(state); + } + } + + /** + * Stop the callback server + */ + stop(): void { + // Reject all pending callbacks + for (const [state, pending] of this.pendingCallbacks) { + clearTimeout(pending.timeout); + pending.reject(new Error('Server stopped')); + this.pendingCallbacks.delete(state); + } + + this.server?.stop(); + this.server = null; + debug('OAuth callback server stopped'); + } + + /** + * Check if server is running + */ + isRunning(): boolean { + return this.server !== null; + } + + /** + * Get the port the server is listening on + */ + getPort(): number { + if (!this.server || this.server.port === undefined) { + throw new Error('Server not running or port not available'); + } + return this.server.port; + } + + /** + * Get the redirect URL for OAuth + * Returns http://localhost/callback for port 80 (standard OAuth format) + */ + getRedirectUrl(): string { + const port = this.getPort(); + // Standard HTTP port doesn't need to be specified in URL + if (port === 80) { + return 'http://localhost/callback'; + } + return `http://localhost:${port}/callback`; + } +} + +// Export singleton instance +export const oauthCallbackServer = OAuthCallbackServer.getInstance(); diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..cd38290 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,24 @@ +/** + * OAuth Authentication - Re-exports for MCP CLI + */ + +export { + CliOAuthProvider, + createOAuthProvider, + ensureCallbackServerRunning, + stopCallbackServer, + getCallbackServerPort, +} from './oauth-provider.js'; + +export { oauthCallbackServer } from './callback-server.js'; + +export { + getStoredOAuthData, + saveOAuthData, + clearOAuthData, + clearAllOAuthData, +} from './token-storage.js'; + +export type { CliOAuthProviderOptions } from './oauth-provider.js'; +export type { CallbackData } from './callback-server.js'; +export type { StoredOAuthData } from './token-storage.js'; diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts new file mode 100644 index 0000000..e4ea49c --- /dev/null +++ b/src/auth/oauth-provider.ts @@ -0,0 +1,296 @@ +/** + * OAuth Client Provider - Implements OAuthClientProvider for MCP CLI + * + * This provider handles the full OAuth flow: + * 1. Checks for existing tokens + * 2. Redirects user to authorization if needed + * 3. Handles the callback and exchanges code for tokens + * 4. Persists tokens to disk for future use + */ + +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import { debug } from '../config.js'; +import { oauthCallbackServer } from './callback-server.js'; +import { + clearOAuthData, + getStoredOAuthData, + saveOAuthData, +} from './token-storage.js'; + +/** + * Options for the CLI OAuth provider + */ +export interface CliOAuthProviderOptions { + /** The server URL (used for token storage key) */ + serverUrl: string; + /** Client name to display during registration */ + clientName?: string; + /** Callback when user needs to authorize in browser */ + onAuthorizationUrl?: (url: URL) => void | Promise; +} + +/** + * Generate a random state parameter for CSRF protection + */ +function generateState(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join( + '', + ); +} + +/** + * Open a URL in the user's default browser + */ +async function openBrowser(url: string): Promise { + try { + const { exec } = await import('node:child_process'); + const platform = process.platform; + + let command: string; + if (platform === 'darwin') { + command = `open "${url}"`; + } else if (platform === 'win32') { + command = `start "" "${url}"`; + } else { + // Linux and others + command = `xdg-open "${url}"`; + } + + exec(command, (error) => { + if (error) { + debug(`Failed to open browser: ${error.message}`); + } + }); + + return true; + } catch (error) { + debug(`Failed to open browser: ${(error as Error).message}`); + return false; + } +} + +/** + * CLI OAuth Client Provider + * + * Implements the full OAuth authorization code flow with PKCE for CLI applications. + * Tokens are persisted to disk for reuse across sessions. + */ +export class CliOAuthProvider implements OAuthClientProvider { + private readonly serverUrl: string; + private readonly _clientMetadata: OAuthClientMetadata; + private _clientInfo?: OAuthClientInformationMixed; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + private _state?: string; + private onAuthorizationUrl?: (url: URL) => void | Promise; + + constructor(options: CliOAuthProviderOptions) { + this.serverUrl = options.serverUrl; + this.onAuthorizationUrl = options.onAuthorizationUrl; + + // Load stored data + const stored = getStoredOAuthData(this.serverUrl); + if (stored) { + this._tokens = stored.tokens; + if (stored.clientId) { + this._clientInfo = { + client_id: stored.clientId, + client_secret: stored.clientSecret, + }; + } + this._codeVerifier = stored.codeVerifier; + } + + // Define client metadata for dynamic registration + // Note: Some MCP servers (like Datadog's) use a pre-registered shared client + // and will return fixed redirect_uris regardless of what we send + this._clientMetadata = { + client_name: options.clientName || 'MCP CLI', + redirect_uris: ['http://localhost/callback'], // Standard OAuth localhost callback + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', // Public client + }; + } + + /** + * Get the redirect URL for OAuth + * Uses http://localhost/callback which is commonly pre-registered for CLI tools + */ + get redirectUrl(): string | URL { + // Use standard OAuth localhost callback (port 80) + // This matches the pre-registered redirect_uri in most MCP OAuth servers + return 'http://localhost/callback'; + } + + /** + * Get client metadata for dynamic registration + */ + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + /** + * Generate OAuth state parameter + */ + state(): string { + this._state = generateState(); + return this._state; + } + + /** + * Get the last generated state parameter + * Used to match against callback + */ + getLastState(): string | undefined { + return this._state; + } + + /** + * Get stored client information + */ + clientInformation(): OAuthClientInformationMixed | undefined { + return this._clientInfo; + } + + /** + * Save client information after dynamic registration + */ + saveClientInformation(clientInformation: OAuthClientInformationMixed): void { + this._clientInfo = clientInformation; + saveOAuthData(this.serverUrl, { + clientId: clientInformation.client_id, + clientSecret: clientInformation.client_secret, + }); + debug(`Saved client information for ${this.serverUrl}`); + } + + /** + * Get stored tokens + */ + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + /** + * Save tokens after successful authorization + */ + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + saveOAuthData(this.serverUrl, { tokens }); + debug(`Saved tokens for ${this.serverUrl}`); + } + + /** + * Redirect user to authorization URL + * Opens browser and waits for callback + */ + async redirectToAuthorization(authorizationUrl: URL): Promise { + // Notify caller about the authorization URL + if (this.onAuthorizationUrl) { + await this.onAuthorizationUrl(authorizationUrl); + } + + // Open browser for user authorization + console.error('\nOpening browser for authorization...'); + console.error( + `If browser doesn't open, visit: ${authorizationUrl.toString()}\n`, + ); + + const opened = await openBrowser(authorizationUrl.toString()); + if (!opened) { + console.error( + `Please open this URL manually: ${authorizationUrl.toString()}`, + ); + } + } + + /** + * Save PKCE code verifier + */ + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + saveOAuthData(this.serverUrl, { codeVerifier }); + } + + /** + * Get PKCE code verifier + */ + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } + + /** + * Invalidate credentials when server indicates they're invalid + */ + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): void { + switch (scope) { + case 'all': + clearOAuthData(this.serverUrl); + this._clientInfo = undefined; + this._tokens = undefined; + this._codeVerifier = undefined; + break; + case 'client': + this._clientInfo = undefined; + saveOAuthData(this.serverUrl, { + clientId: undefined, + clientSecret: undefined, + }); + break; + case 'tokens': + this._tokens = undefined; + saveOAuthData(this.serverUrl, { tokens: undefined }); + break; + case 'verifier': + this._codeVerifier = undefined; + saveOAuthData(this.serverUrl, { codeVerifier: undefined }); + break; + } + debug(`Invalidated ${scope} credentials for ${this.serverUrl}`); + } +} + +/** + * Create an OAuth provider for a server URL + */ +export function createOAuthProvider( + serverUrl: string, + options?: Partial, +): CliOAuthProvider { + return new CliOAuthProvider({ + serverUrl, + ...options, + }); +} + +/** + * Start the OAuth callback server if not already running + */ +export async function ensureCallbackServerRunning(): Promise { + await oauthCallbackServer.ensureRunning(); +} + +/** + * Stop the OAuth callback server + */ +export function stopCallbackServer(): void { + oauthCallbackServer.stop(); +} + +/** + * Get the callback server port + */ +export function getCallbackServerPort(): number { + return oauthCallbackServer.getPort(); +} diff --git a/src/auth/token-storage.ts b/src/auth/token-storage.ts new file mode 100644 index 0000000..9a16d48 --- /dev/null +++ b/src/auth/token-storage.ts @@ -0,0 +1,124 @@ +/** + * Token Storage - Persists OAuth tokens to disk + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { debug } from '../config.js'; + +/** + * Stored OAuth data for a server + */ +export interface StoredOAuthData { + tokens?: OAuthTokens; + clientId?: string; + clientSecret?: string; + codeVerifier?: string; +} + +/** + * All stored OAuth data + */ +interface OAuthStorageFile { + version: 1; + servers: Record; +} + +/** + * Get the path to the OAuth storage file + */ +function getStoragePath(): string { + return join(homedir(), '.config', 'mcp', 'oauth_tokens.json'); +} + +/** + * Load stored OAuth data from disk + */ +function loadStorage(): OAuthStorageFile { + const path = getStoragePath(); + try { + if (existsSync(path)) { + const content = readFileSync(path, 'utf-8'); + const data = JSON.parse(content) as OAuthStorageFile; + if (data.version === 1 && data.servers) { + return data; + } + } + } catch (error) { + debug(`Failed to load OAuth storage: ${(error as Error).message}`); + } + return { version: 1, servers: {} }; +} + +/** + * Save OAuth data to disk + */ +function saveStorage(data: OAuthStorageFile): void { + const path = getStoragePath(); + try { + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(path, JSON.stringify(data, null, 2), { mode: 0o600 }); + debug(`Saved OAuth storage to ${path}`); + } catch (error) { + debug(`Failed to save OAuth storage: ${(error as Error).message}`); + } +} + +/** + * Get a unique key for an OAuth server based on its URL + */ +function getServerKey(serverUrl: string): string { + try { + const url = new URL(serverUrl); + // Use origin + pathname as the key to differentiate between different endpoints + return `${url.origin}${url.pathname}`; + } catch { + return serverUrl; + } +} + +/** + * Get stored OAuth data for a server + */ +export function getStoredOAuthData( + serverUrl: string, +): StoredOAuthData | undefined { + const storage = loadStorage(); + const key = getServerKey(serverUrl); + return storage.servers[key]; +} + +/** + * Save OAuth data for a server + */ +export function saveOAuthData( + serverUrl: string, + data: Partial, +): void { + const storage = loadStorage(); + const key = getServerKey(serverUrl); + storage.servers[key] = { ...storage.servers[key], ...data }; + saveStorage(storage); +} + +/** + * Clear OAuth data for a server + */ +export function clearOAuthData(serverUrl: string): void { + const storage = loadStorage(); + const key = getServerKey(serverUrl); + delete storage.servers[key]; + saveStorage(storage); +} + +/** + * Clear all OAuth data + */ +export function clearAllOAuthData(): void { + saveStorage({ version: 1, servers: {} }); +} diff --git a/src/client.ts b/src/client.ts index 50731cf..7ff1e02 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,10 +2,17 @@ * MCP Client - Connection management for MCP servers */ +import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { oauthCallbackServer } from './auth/callback-server.js'; +import { + CliOAuthProvider, + ensureCallbackServerRunning, + stopCallbackServer, +} from './auth/oauth-provider.js'; import { type HttpServerConfig, type ServerConfig, @@ -217,6 +224,7 @@ export async function safeClose(close: () => Promise): Promise { /** * Connect to an MCP server with retry logic * Captures stderr from stdio servers to include in error messages + * Supports OAuth authentication for HTTP servers */ export async function connectToServer( serverName: string, @@ -226,6 +234,12 @@ export async function connectToServer( const stderrChunks: string[] = []; return withRetry(async () => { + // For HTTP servers with OAuth, we may need to retry after auth + if (isHttpServer(config)) { + return await connectHttpServer(serverName, config); + } + + // For stdio servers, standard connection const client = new Client( { name: 'mcp-cli', @@ -236,24 +250,18 @@ export async function connectToServer( }, ); - let transport: StdioClientTransport | StreamableHTTPClientTransport; - - if (isHttpServer(config)) { - transport = createHttpTransport(config); - } else { - transport = createStdioTransport(config); - - // Capture stderr for debugging - attach BEFORE connect - // Always stream stderr immediately so auth prompts are visible - const stderrStream = transport.stderr; - if (stderrStream) { - stderrStream.on('data', (chunk: Buffer) => { - const text = chunk.toString(); - stderrChunks.push(text); - // Always stream stderr immediately so users can see auth prompts - process.stderr.write(`[${serverName}] ${text}`); - }); - } + const transport = createStdioTransport(config); + + // Capture stderr for debugging - attach BEFORE connect + // Always stream stderr immediately so auth prompts are visible + const stderrStream = transport.stderr; + if (stderrStream) { + stderrStream.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + stderrChunks.push(text); + // Always stream stderr immediately so users can see auth prompts + process.stderr.write(`[${serverName}] ${text}`); + }); } try { @@ -269,13 +277,10 @@ export async function connectToServer( } // For successful connections, forward stderr to console - if (!isHttpServer(config)) { - const stderrStream = (transport as StdioClientTransport).stderr; - if (stderrStream) { - stderrStream.on('data', (chunk: Buffer) => { - process.stderr.write(chunk); - }); - } + if (stderrStream) { + stderrStream.on('data', (chunk: Buffer) => { + process.stderr.write(chunk); + }); } return { @@ -287,19 +292,162 @@ export async function connectToServer( }, `connect to ${serverName}`); } +/** + * Connect to an HTTP MCP server with OAuth support + */ +async function connectHttpServer( + serverName: string, + config: HttpServerConfig, +): Promise { + const { transport, oauthProvider } = await createHttpTransport( + serverName, + config, + ); + + const client = new Client( + { + name: 'mcp-cli', + version: VERSION, + }, + { + capabilities: {}, + }, + ); + + try { + await client.connect(transport); + return { + client, + close: async () => { + await client.close(); + stopCallbackServer(); + }, + }; + } catch (error) { + // Handle OAuth redirect - user needs to complete authorization + if (error instanceof UnauthorizedError && oauthProvider) { + debug(`OAuth redirect required for ${serverName}`); + + // Get the state that was used in the authorization URL + // (state() was called by the SDK when building the auth URL) + const state = oauthProvider.getLastState(); + if (!state) { + throw new Error( + 'OAuth state not found - authorization flow may have failed', + ); + } + + try { + console.error('Waiting for authorization...'); + const callbackData = await oauthCallbackServer.waitForCallback( + state, + 300000, + ); + + if (callbackData.error) { + throw new Error( + `OAuth error: ${callbackData.errorDescription || callbackData.error}`, + ); + } + + // Exchange the authorization code for tokens + // finishAuth stores the tokens in the provider + await transport.finishAuth(callbackData.code); + + // Create a NEW transport and client for the actual connection + // (the old transport is already "started" and can't be reused) + const { transport: newTransport } = await createHttpTransport( + serverName, + config, + ); + + const newClient = new Client( + { + name: 'mcp-cli', + version: VERSION, + }, + { + capabilities: {}, + }, + ); + + await newClient.connect(newTransport); + + return { + client: newClient, + close: async () => { + await newClient.close(); + stopCallbackServer(); + }, + }; + } catch (authError) { + // Clean up callback server on auth failure + stopCallbackServer(); + throw authError; + } + } + + throw error; + } +} + /** * Create HTTP transport for remote servers + * Supports OAuth authentication when configured */ -function createHttpTransport( +async function createHttpTransport( + serverName: string, config: HttpServerConfig, -): StreamableHTTPClientTransport { +): Promise<{ + transport: StreamableHTTPClientTransport; + oauthProvider?: CliOAuthProvider; +}> { const url = new URL(config.url); - return new StreamableHTTPClientTransport(url, { + // Check if OAuth is enabled for this server + const oauthEnabled = + config.oauth === true || typeof config.oauth === 'object'; + + if (oauthEnabled) { + // Start the OAuth callback server + await ensureCallbackServerRunning(); + + // Create OAuth provider + const oauthConfig = typeof config.oauth === 'object' ? config.oauth : {}; + const oauthProvider = new CliOAuthProvider({ + serverUrl: config.url, + clientName: oauthConfig.clientName || `mcp-cli (${serverName})`, + onAuthorizationUrl: async (authUrl) => { + debug(`Authorization URL: ${authUrl.toString()}`); + }, + }); + + // If pre-configured client credentials are provided, set them + if (oauthConfig.clientId) { + oauthProvider.saveClientInformation({ + client_id: oauthConfig.clientId, + client_secret: oauthConfig.clientSecret, + }); + } + + const transport = new StreamableHTTPClientTransport(url, { + authProvider: oauthProvider, + requestInit: { + headers: config.headers, + }, + }); + + return { transport, oauthProvider }; + } + + // No OAuth - simple transport + const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers: config.headers, }, }); + + return { transport }; } /** diff --git a/src/config.ts b/src/config.ts index 99a4e25..0cc0008 100644 --- a/src/config.ts +++ b/src/config.ts @@ -48,6 +48,24 @@ export interface HttpServerConfig extends BaseServerConfig { url: string; headers?: Record; timeout?: number; + /** + * Enable OAuth authentication for this server. + * When true, the CLI will perform OAuth flow if the server requires authentication. + * Can also be set to an object with OAuth-specific options. + */ + oauth?: boolean | OAuthConfig; +} + +/** + * OAuth configuration options + */ +export interface OAuthConfig { + /** Client name for dynamic registration */ + clientName?: string; + /** Pre-configured client ID (skip dynamic registration) */ + clientId?: string; + /** Pre-configured client secret */ + clientSecret?: string; } export type ServerConfig = StdioServerConfig | HttpServerConfig; diff --git a/src/index.ts b/src/index.ts index 088deed..c6962bc 100755 --- a/src/index.ts +++ b/src/index.ts @@ -392,6 +392,20 @@ Config File: 2. ./mcp_servers.json (current directory) 3. ~/.mcp_servers.json 4. ~/.config/mcp/mcp_servers.json + +OAuth Authentication: + For HTTP servers that require OAuth, add "oauth": true to the config: + { + "mcpServers": { + "my-server": { + "url": "https://example.com/mcp", + "oauth": true + } + } + } + + On first connection, your browser will open for authorization. + Tokens are cached in ~/.config/mcp/oauth_tokens.json `); } From 2d895e451f25dedfdc884be44f99e597af9a3dfb Mon Sep 17 00:00:00 2001 From: Bruno Volpato Date: Fri, 23 Jan 2026 23:03:31 -0500 Subject: [PATCH 2/2] Fix OAuth callback to use dynamic port instead of hardcoded port 80 - Make redirectUrl getter use actual port from callback server - Update clientMetadata to dynamically resolve redirect_uris - Fixes OAuth flow when port 80 is unavailable (common on macOS/Linux) --- src/auth/oauth-provider.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/auth/oauth-provider.ts b/src/auth/oauth-provider.ts index e4ea49c..7a96412 100644 --- a/src/auth/oauth-provider.ts +++ b/src/auth/oauth-provider.ts @@ -111,9 +111,10 @@ export class CliOAuthProvider implements OAuthClientProvider { // Define client metadata for dynamic registration // Note: Some MCP servers (like Datadog's) use a pre-registered shared client // and will return fixed redirect_uris regardless of what we send + // The redirect_uris will be updated dynamically when clientMetadata getter is called this._clientMetadata = { client_name: options.clientName || 'MCP CLI', - redirect_uris: ['http://localhost/callback'], // Standard OAuth localhost callback + redirect_uris: ['http://localhost/callback'], // Will be overridden dynamically grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'none', // Public client @@ -122,19 +123,30 @@ export class CliOAuthProvider implements OAuthClientProvider { /** * Get the redirect URL for OAuth - * Uses http://localhost/callback which is commonly pre-registered for CLI tools + * Uses the actual port from the callback server */ get redirectUrl(): string | URL { - // Use standard OAuth localhost callback (port 80) - // This matches the pre-registered redirect_uri in most MCP OAuth servers - return 'http://localhost/callback'; + // Get the actual redirect URL from the running callback server + // This ensures we use the port that's actually listening + return oauthCallbackServer.getRedirectUrl(); } /** * Get client metadata for dynamic registration + * Returns metadata with dynamically resolved redirect_uris from the callback server */ get clientMetadata(): OAuthClientMetadata { - return this._clientMetadata; + // Try to get the actual redirect URL from the callback server + try { + const redirectUrl = oauthCallbackServer.getRedirectUrl(); + return { + ...this._clientMetadata, + redirect_uris: [redirectUrl], + }; + } catch { + // Server not running yet, return default metadata + return this._clientMetadata; + } } /**