diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c56163..b04f885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +### Added + +- **Non-Blocking OAuth Flow for AI Agents** + - `AuthRequiredError` now includes authorization URL for immediate action + - Callback server runs in background (5 min timeout) - CLI returns immediately + - `mcp-cli` (list all) shows working servers + auth URLs for servers needing login + - Random port by default to avoid conflicts with multiple OAuth servers + - Updated README with sequence diagram and AI agent guidance + ## [0.3.0] - 2026-01-22 ### Added diff --git a/README.md b/README.md index 4d1c6a3..624852b 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,141 @@ Restrict which tools are available from a server using `allowedTools` and `disab "disabledTools": ["delete_file"] ``` +### OAuth Authentication + +For HTTP MCP servers that require OAuth (like Notion, GitHub, etc.), the CLI handles authentication automatically: + +```mermaid +sequenceDiagram + participant AI as AI Agent + participant CLI as mcp-cli + participant Server as Callback Server + participant User as User + AI->>CLI: mcp-cli + CLI->>CLI: Detect some servers need auth + CLI->>Server: Spawn background callback server + CLI-->>AI: List working servers + auth URL for others + AI->>User: "Please authenticate via this link" + User->>Server: Completes OAuth in browser + Server->>Server: Saves tokens to ~/.mcp-cli/tokens/ + User->>AI: "Done" + AI->>CLI: mcp-cli (retry) + CLI-->>AI: All servers now listed +``` + +**Configuration:** + +Most OAuth-enabled servers work with just a URL - the CLI handles dynamic client registration and PKCE automatically: + +```json +{ + "mcpServers": { + "notion": { + "url": "https://mcp.notion.com/mcp" + } + } +} +``` + +**OAuth Configuration Options:** + +For servers requiring custom OAuth settings, use the `oauth` object: + +```json +{ + "mcpServers": { + "my-server": { + "url": "https://api.example.com/mcp", + "oauth": { + "callbackPort": 8095, + "clientId": "your-client-id", + "clientSecret": "your-client-secret", + "scope": "read write", + "grantType": "authorization_code" + } + } + } +} +``` + +| Option | Description | Default | +|--------|-------------|---------| +| `callbackPort` | Port for OAuth callback server | Random | +| `clientId` | Pre-registered OAuth client ID | (dynamic registration) | +| `clientSecret` | OAuth client secret (for confidential clients) | (none) | +| `scope` | OAuth scopes to request | (none) | +| `grantType` | `authorization_code` or `client_credentials` | `authorization_code` | + +**Examples by scenario:** + +1. **Public server with dynamic registration** (most common): +```json +{ + "notion": { "url": "https://mcp.notion.com/mcp" } +} +``` + +2. **Server with pre-registered public client:** +```json +{ + "github": { + "url": "https://mcp.github.com/mcp", + "oauth": { "clientId": "abc123" } + } +} +``` + +3. **Confidential client (client_credentials):** +```json +{ + "internal-api": { + "url": "https://api.internal.com/mcp", + "oauth": { + "clientId": "service-account", + "clientSecret": "secret-key", + "grantType": "client_credentials" + } + } +} +``` + +**Token Storage:** + +Tokens are persisted in `~/.mcp-cli/` with secure file permissions (0600): + +``` +~/.mcp-cli/ +├── tokens/server-name.json # Access/refresh tokens +├── clients/server-name.json # Dynamic client registration +└── verifiers/server-name.txt # PKCE verifiers (temporary) +``` + +**Clearing cached tokens:** + +```bash +rm ~/.mcp-cli/tokens/notion.json # Re-authenticate on next call +rm -rf ~/.mcp-cli/ # Clear all OAuth data +``` + +**For AI Agents (non-interactive mode):** + +When listing multiple servers, working servers show their tools while auth-required servers display an actionable auth URL: + +``` +deepwiki + • read_wiki_structure + • read_wiki_contents + • ask_question + +notion + • +``` + +The callback server stays running in the background. After the user authenticates and confirms, retry the command to see all servers. + ### Config Resolution The CLI searches for configuration in this order: @@ -360,6 +495,7 @@ The CLI searches for configuration in this order: | `MCP_STRICT_ENV` | Error on missing `${VAR}` in config | `true` | | `MCP_NO_DAEMON` | Disable connection caching (force fresh connections) | `false` | | `MCP_DAEMON_TIMEOUT` | Idle timeout for cached connections (seconds) | `60` | +| `MCP_CLI_HOME` | Override OAuth storage directory (default: `$HOME`) | (none) | ## Using with AI Agents diff --git a/src/client.ts b/src/client.ts index 50731cf..1499466 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2,6 +2,7 @@ * 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'; @@ -25,6 +26,8 @@ import { cleanupOrphanedDaemons, getDaemonConnection, } from './daemon-client.js'; +import { formatCliError, oauthFlowError } from './errors.js'; +import { McpCliOAuthProvider } from './oauth/index.js'; import { VERSION } from './version.js'; // Re-export config utilities for convenience @@ -49,6 +52,30 @@ export interface McpConnection { isDaemon: boolean; } +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type ConnectOptions = Record; + +/** + * Error thrown when authentication is required but interactive auth is disabled + */ +export class AuthRequiredError extends Error { + public readonly serverName: string; + public readonly authUrl: string | undefined; + + constructor(serverName: string, authUrl?: string) { + const message = authUrl + ? `[AUTH REQUIRED] ${serverName} + Authenticate at: ${authUrl} + Callback server running in background (5 min timeout). + After authenticating, confirm "done" and retry this command.` + : `Server "${serverName}" requires authentication. Run 'mcp-cli info ${serverName}' to start authentication.`; + super(message); + this.name = 'AuthRequiredError'; + this.serverName = serverName; + this.authUrl = authUrl; + } +} + export interface ServerInfo { name: string; version?: string; @@ -95,6 +122,11 @@ function getRetryConfig(): RetryConfig { * Uses error codes when available, falls back to message matching */ export function isTransientError(error: Error): boolean { + // AuthRequiredError should never be retried - it indicates callback server is running + if (error.name === 'AuthRequiredError') { + return false; + } + // Check error code first (more reliable than message matching) const nodeError = error as NodeJS.ErrnoException; if (nodeError.code) { @@ -217,10 +249,12 @@ 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 + * Handles OAuth flow for HTTP servers with authentication */ export async function connectToServer( serverName: string, config: ServerConfig, + options: ConnectOptions = {}, ): Promise { // Collect stderr for better error messages const stderrChunks: string[] = []; @@ -237,9 +271,29 @@ export async function connectToServer( ); let transport: StdioClientTransport | StreamableHTTPClientTransport; + let authProvider: McpCliOAuthProvider | undefined; if (isHttpServer(config)) { - transport = createHttpTransport(config); + const result = createHttpTransport(serverName, config); + transport = result.transport; + authProvider = result.authProvider; + + // Pre-start callback server for authorization_code flow to determine actual port + // This ensures the redirect_uri in the authorization request uses the correct port + const oauthConfig = (config as HttpServerConfig).oauth; + if ( + !oauthConfig?.grantType || + oauthConfig.grantType === 'authorization_code' + ) { + try { + await authProvider.preStartCallbackServer(); + } catch (error) { + debug( + `Failed to pre-start callback server: ${(error as Error).message}`, + ); + // Continue anyway - server will start during redirectToAuthorization + } + } } else { transport = createStdioTransport(config); @@ -259,6 +313,17 @@ export async function connectToServer( try { await client.connect(transport); } catch (error) { + if (isOAuthNeeded(error as Error) && authProvider) { + debug(`OAuth authorization required for ${serverName}`); + + // Always throw AuthRequiredError with auth URL - CLI is for AI agents + // Callback server continues running in background for 5 min + throw new AuthRequiredError( + serverName, + authProvider.capturedAuthUrl ?? undefined, + ); + } + // Enhance error with captured stderr const stderrOutput = stderrChunks.join('').trim(); if (stderrOutput) { @@ -289,17 +354,52 @@ export async function connectToServer( /** * Create HTTP transport for remote servers + * Always creates an auth provider to handle OAuth flows (server-initiated or explicit) */ function createHttpTransport( + serverName: string, config: HttpServerConfig, -): StreamableHTTPClientTransport { +): { + transport: StreamableHTTPClientTransport; + authProvider: McpCliOAuthProvider; +} { const url = new URL(config.url); - return new StreamableHTTPClientTransport(url, { + // Always create OAuth provider for HTTP servers to handle server-initiated OAuth + // If explicit oauth config exists, use it; otherwise use defaults + const oauthConfig = config.oauth || {}; + const authProvider = new McpCliOAuthProvider( + serverName, + config.url, + oauthConfig, + ); + debug( + `OAuth provider created for ${serverName} (${oauthConfig.grantType || 'authorization_code'})`, + ); + + const transport = new StreamableHTTPClientTransport(url, { + authProvider, requestInit: { headers: config.headers, }, }); + + return { transport, authProvider }; +} + +/** + * Check if an error indicates OAuth authorization is needed + */ +function isOAuthNeeded(error: Error): boolean { + // Check for UnauthorizedError from SDK + if (error instanceof UnauthorizedError) { + return true; + } + // Check for invalid_token error in message (common OAuth error response) + if (error.message.includes('invalid_token')) { + return true; + } + return false; } /** @@ -390,6 +490,7 @@ export async function callTool( export async function getConnection( serverName: string, config: ServerConfig, + options: ConnectOptions = {}, ): Promise { // Clean up any orphaned daemons on first call await cleanupOrphanedDaemons(); @@ -437,7 +538,7 @@ export async function getConnection( // Fall back to direct connection debug(`Using direct connection for ${serverName}`); - const { client, close } = await connectToServer(serverName, config); + const { client, close } = await connectToServer(serverName, config, options); return { async listTools(): Promise { diff --git a/src/commands/list.ts b/src/commands/list.ts index 82d1014..fd549d0 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -3,6 +3,7 @@ */ import { + AuthRequiredError, type McpConnection, type ToolInfo, debug, @@ -62,6 +63,7 @@ async function processWithConcurrency( /** * Fetch tools from a single server (uses daemon if enabled) + * Never opens browser - CLI is used by AI agents, returns auth URL instead */ async function fetchServerTools( serverName: string, @@ -77,6 +79,17 @@ async function fetchServerTools( debug(`${serverName}: loaded ${tools.length} tools`); return { name: serverName, tools, instructions }; } catch (error) { + // AuthRequiredError is caught and returned as a response, not retried + // This ensures the callback server stays running and the auth URL is shown + if (error instanceof AuthRequiredError) { + debug(`${serverName}: auth required - ${error.message}`); + return { + name: serverName, + tools: [], + error: error.message, + }; + } + const errorMsg = (error as Error).message; debug(`${serverName}: connection failed - ${errorMsg}`); return { diff --git a/src/config.ts b/src/config.ts index 99a4e25..4dc152d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,6 +41,19 @@ export interface StdioServerConfig extends BaseServerConfig { cwd?: string; } +/** + * OAuth configuration for HTTP servers + */ +export interface OAuthConfig { + grantType?: 'authorization_code' | 'client_credentials'; + clientId?: string; + clientSecret?: string; + scope?: string; + callbackPort?: number; + /** Optional: explicit list of ports to try in order (overrides default fallback) */ + callbackPorts?: number[]; +} + /** * HTTP server configuration (remote) */ @@ -48,6 +61,7 @@ export interface HttpServerConfig extends BaseServerConfig { url: string; headers?: Record; timeout?: number; + oauth?: OAuthConfig; } export type ServerConfig = StdioServerConfig | HttpServerConfig; @@ -494,6 +508,68 @@ export async function loadConfig( }), ); } + + // Validate OAuth config if present (only for HTTP servers) + if (hasUrl && 'oauth' in serverConfig && serverConfig.oauth) { + const oauth = serverConfig.oauth as Record; + + // Validate grantType if specified + if ( + oauth.grantType && + oauth.grantType !== 'authorization_code' && + oauth.grantType !== 'client_credentials' + ) { + throw new Error( + formatCliError({ + code: ErrorCode.CLIENT_ERROR, + type: 'CONFIG_INVALID_OAUTH', + message: `Invalid OAuth grantType for server "${serverName}"`, + details: `Got "${oauth.grantType}", expected "authorization_code" or "client_credentials"`, + suggestion: `Use "authorization_code" for interactive login or "client_credentials" for machine-to-machine auth`, + }), + ); + } + + // client_credentials requires clientId and clientSecret + if (oauth.grantType === 'client_credentials') { + if (!oauth.clientId) { + throw new Error( + formatCliError({ + code: ErrorCode.CLIENT_ERROR, + type: 'CONFIG_INVALID_OAUTH', + message: `OAuth client_credentials flow requires clientId for server "${serverName}"`, + suggestion: `Add "clientId" to the oauth config, e.g., "clientId": "\${MCP_CLIENT_ID}"`, + }), + ); + } + if (!oauth.clientSecret) { + throw new Error( + formatCliError({ + code: ErrorCode.CLIENT_ERROR, + type: 'CONFIG_INVALID_OAUTH', + message: `OAuth client_credentials flow requires clientSecret for server "${serverName}"`, + suggestion: `Add "clientSecret" to the oauth config, e.g., "clientSecret": "\${MCP_CLIENT_SECRET}"`, + }), + ); + } + } + + // Validate callbackPort if specified + if (oauth.callbackPort !== undefined) { + const port = Number(oauth.callbackPort); + if (Number.isNaN(port) || port < 1 || port > 65535) { + throw new Error( + formatCliError({ + code: ErrorCode.CLIENT_ERROR, + type: 'CONFIG_INVALID_OAUTH', + message: `Invalid callbackPort for server "${serverName}"`, + details: 'Port must be a number between 1 and 65535', + suggestion: `Use a valid port number, e.g., "callbackPort": 8095`, + }), + ); + } + } + } } // Substitute environment variables diff --git a/src/errors.ts b/src/errors.ts index 7883799..50534eb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -384,3 +384,46 @@ export function tooManyArgumentsError( suggestion: `Run 'mcp-cli --help' for correct usage`, }; } + +// ============================================================================ +// OAuth Errors +// ============================================================================ + +export function oauthConfigError( + serverName: string, + details: string, +): CliError { + return { + code: ErrorCode.CLIENT_ERROR, + type: 'OAUTH_CONFIG_ERROR', + message: `Invalid OAuth configuration for server "${serverName}"`, + details, + suggestion: 'Check your mcp_servers.json OAuth configuration', + }; +} + +export function oauthFlowError(serverName: string, cause: string): CliError { + let suggestion = 'Try again or check your OAuth server configuration'; + + if (cause.includes('timeout')) { + suggestion = + 'Authorization timed out. Try again and complete the browser authorization within the time limit'; + } else if (cause.includes('callback')) { + suggestion = + 'Failed to receive OAuth callback. Ensure your firewall allows connections to localhost'; + } else if (cause.includes('token')) { + suggestion = + 'Token exchange failed. Check your client credentials and OAuth server configuration'; + } else if (cause.includes('refresh')) { + suggestion = + 'Token refresh failed. Your session may have expired - try re-authenticating'; + } + + return { + code: ErrorCode.AUTH_ERROR, + type: 'OAUTH_FLOW_ERROR', + message: `OAuth authentication failed for server "${serverName}"`, + details: cause, + suggestion, + }; +} diff --git a/src/oauth/browser.ts b/src/oauth/browser.ts new file mode 100644 index 0000000..ea46dff --- /dev/null +++ b/src/oauth/browser.ts @@ -0,0 +1,41 @@ +/** + * Browser utilities for OAuth + */ + +import { exec } from 'node:child_process'; +import { platform } from 'node:os'; +import { debug } from '../config.js'; + +/** + * Open URL in default browser (cross-platform) + */ +export function openBrowser(url: string): Promise { + return new Promise((resolve, reject) => { + const os = platform(); + let command: string; + + switch (os) { + case 'darwin': + command = `open "${url}"`; + break; + case 'win32': + command = `start "" "${url}"`; + break; + default: + // Linux and others + command = `xdg-open "${url}"`; + } + + debug(`Opening browser: ${command}`); + + exec(command, (error) => { + if (error) { + console.error(`Failed to open browser: ${error.message}`); + console.error(`Please manually open: ${url}`); + reject(error); + } else { + resolve(); + } + }); + }); +} diff --git a/src/oauth/callback-server.ts b/src/oauth/callback-server.ts new file mode 100644 index 0000000..41136bb --- /dev/null +++ b/src/oauth/callback-server.ts @@ -0,0 +1,264 @@ +/** + * OAuth callback server + * + * HTTP server that listens for OAuth authorization callbacks + * and extracts the authorization code. + */ + +import { createServer } from 'node:http'; +import { debug } from '../config.js'; +import type { CallbackServerState, OAuthCallbackResult } from './types.js'; + +/** + * Pretty HTML template for successful OAuth callback + */ +const SUCCESS_HTML = ` + + + Authorization Successful + + + +
+
+ + + +
+

Authorization Successful

+

You can close this window and return to the terminal.

+
+ + +`; + +/** + * Pretty HTML template for OAuth error + */ +function errorHtml(message: string): string { + const escapedMessage = message + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + return ` + + + Authorization Failed + + + +
+
+ + + +
+

Authorization Failed

+

An error occurred during authorization.

+
${escapedMessage}
+
+ +`; +} + +/** + * Start an OAuth callback server that listens for the authorization code + * Returns immediately with a promise that resolves when the callback is received + */ +export function startCallbackServer( + port: number, + timeoutMs = 300000, +): Promise { + return new Promise((resolveStart, rejectStart) => { + let callbackResolve: (result: OAuthCallbackResult) => void; + let callbackReject: (error: Error) => void; + let timeoutId: ReturnType | undefined; + let server: ReturnType | undefined; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + if (server) { + server.close(); + server = undefined; + } + }; + + const callbackPromise = new Promise( + (resolve, reject) => { + callbackResolve = resolve; + callbackReject = reject; + }, + ); + + timeoutId = setTimeout(() => { + cleanup(); + callbackReject( + new Error('OAuth callback timeout - no authorization code received'), + ); + }, timeoutMs); + + server = createServer((req, res) => { + // Ignore favicon requests + if (req.url === '/favicon.ico') { + res.writeHead(404); + res.end(); + return; + } + + debug(`Received OAuth callback: ${req.url}`); + + const parsedUrl = new URL(req.url || '', `http://localhost:${port}`); + const code = parsedUrl.searchParams.get('code'); + const error = parsedUrl.searchParams.get('error'); + const errorDescription = parsedUrl.searchParams.get('error_description'); + + if (code) { + debug(`Authorization code received: ${code.substring(0, 10)}...`); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(SUCCESS_HTML); + + cleanup(); + callbackResolve({ code }); + } else if (error) { + const message = errorDescription || error; + debug(`OAuth error: ${message}`); + + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(errorHtml(message)); + + cleanup(); + callbackReject(new Error(`OAuth authorization failed: ${message}`)); + } else { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Bad request: missing authorization code'); + } + }); + + server.on('error', (error) => { + cleanup(); + rejectStart( + new Error(`Failed to start OAuth callback server: ${error.message}`), + ); + }); + + server.listen(port, () => { + debug( + `OAuth callback server listening on http://localhost:${port}/callback`, + ); + const httpServer = server; + if (httpServer) { + resolveStart({ + server: httpServer, + promise: callbackPromise, + cleanup, + }); + } + }); + }); +} + +/** + * Start an OAuth callback server with port fallback + * Tries ports in order until one succeeds + * @returns The server state and the actual port used + */ +export async function startCallbackServerWithFallback( + portsToTry: number[], + timeoutMs = 300000, +): Promise<{ state: CallbackServerState; actualPort: number }> { + const errors: string[] = []; + + for (const port of portsToTry) { + try { + debug( + `Trying to start callback server on port ${port === 0 ? 'random' : port}`, + ); + const state = await startCallbackServer(port, timeoutMs); + // Get the actual port (important when port was 0 for random) + const address = state.server.address(); + const actualPort = + typeof address === 'object' && address ? address.port : port; + debug(`Callback server started on port ${actualPort}`); + return { state, actualPort }; + } catch (error) { + const message = (error as Error).message; + debug(`Port ${port} failed: ${message}`); + errors.push(`Port ${port}: ${message}`); + // Continue to next port + } + } + + // All ports failed + throw new Error( + `Failed to start OAuth callback server. Tried ports: ${portsToTry.join(', ')}.\n${errors.join('\n')}`, + ); +} diff --git a/src/oauth/index.ts b/src/oauth/index.ts new file mode 100644 index 0000000..91a21b3 --- /dev/null +++ b/src/oauth/index.ts @@ -0,0 +1,58 @@ +/** + * OAuth module for mcp-cli + * + * Provides OAuth authentication support for HTTP MCP servers, + * including authorization code flow with PKCE and client credentials flow. + */ + +// Types +export { + type CallbackServerState, + DEFAULT_CALLBACK_PORT, + DEFAULT_PORT_FALLBACK_ORDER, + type OAuthCallbackResult, + type OAuthConfig, +} from './types.js'; + +// Storage utilities +export { + deleteFile, + ensureDir, + getMcpCliDir, + getServerPaths, + getStoragePaths, + readJsonFile, + readTextFile, + sanitizeServerName, + writeJsonFile, + writeTextFile, +} from './storage.js'; + +// Browser utilities +export { openBrowser } from './browser.js'; + +// Callback server +export { + startCallbackServer, + startCallbackServerWithFallback, +} from './callback-server.js'; + +// Main provider +export { McpCliOAuthProvider } from './provider.js'; + +// Legacy export for backwards compatibility +import { startCallbackServer } from './callback-server.js'; +import { DEFAULT_CALLBACK_PORT, type OAuthCallbackResult } from './types.js'; + +/** + * Wait for OAuth callback on local server + * Returns the authorization code from the callback + * @deprecated Use McpCliOAuthProvider.waitForCallback() instead + */ +export async function waitForOAuthCallback( + port: number = DEFAULT_CALLBACK_PORT, + timeoutMs = 300000, +): Promise { + const state = await startCallbackServer(port, timeoutMs); + return state.promise; +} diff --git a/src/oauth/provider.ts b/src/oauth/provider.ts new file mode 100644 index 0000000..68f995e --- /dev/null +++ b/src/oauth/provider.ts @@ -0,0 +1,400 @@ +/** + * MCP CLI OAuth Provider + * + * Implements the OAuthClientProvider interface with file-based persistence + * for tokens, client information, and PKCE verifiers. + */ + +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 { openBrowser } from './browser.js'; +import { startCallbackServerWithFallback } from './callback-server.js'; +import { + deleteFile, + ensureDir, + getServerPaths, + getStoragePaths, + readJsonFile, + readTextFile, + writeJsonFile, + writeTextFile, +} from './storage.js'; +import { + type CallbackServerState, + DEFAULT_CALLBACK_PORT, + DEFAULT_PORT_FALLBACK_ORDER, + type OAuthCallbackResult, + type OAuthConfig, +} from './types.js'; + +export class McpCliOAuthProvider implements OAuthClientProvider { + private readonly serverName: string; + private readonly oauthConfig: OAuthConfig; + private readonly serverUrl: string; + private readonly paths: { tokens: string; client: string; verifier: string }; + + // Callback server state - started before browser opens + private callbackServerState: CallbackServerState | null = null; + private callbackServerStarting: Promise | null = null; + + // Actual port used by callback server (may differ from configured port due to fallback) + private actualCallbackPort: number | null = null; + + // Captured authorization URL for non-interactive mode + private _capturedAuthUrl: string | null = null; + + constructor(serverName: string, serverUrl: string, oauthConfig: OAuthConfig) { + this.serverName = serverName; + this.serverUrl = serverUrl; + this.oauthConfig = oauthConfig; + this.paths = getServerPaths(serverName); + + // Ensure storage directories exist + const dirs = getStoragePaths(); + ensureDir(dirs.tokens); + ensureDir(dirs.clients); + ensureDir(dirs.verifiers); + } + + /** + * Redirect URL for OAuth callback + * Returns undefined for client_credentials flow (non-interactive) + * Uses actualCallbackPort if pre-started, otherwise configured/default port + * Omits port from URL when using port 80 (standard HTTP port) + */ + get redirectUrl(): string | URL | undefined { + if (this.oauthConfig.grantType === 'client_credentials') { + return undefined; + } + // Use actual port if server was pre-started, otherwise fall back to configured/default + const port = + this.actualCallbackPort ?? + this.oauthConfig.callbackPort ?? + DEFAULT_CALLBACK_PORT; + // Omit port from URL when using standard HTTP port 80 + if (port === 80) { + return 'http://localhost/callback'; + } + return `http://localhost:${port}/callback`; + } + + /** + * OAuth client metadata + */ + get clientMetadata(): OAuthClientMetadata { + const redirectUrl = this.redirectUrl; + const grantTypes = + this.oauthConfig.grantType === 'client_credentials' + ? ['client_credentials'] + : ['authorization_code', 'refresh_token']; + + // Convert URL to string if needed + const redirectUriStr = redirectUrl ? String(redirectUrl) : undefined; + + return { + client_name: `mcp-cli (${this.serverName})`, + // Zod validates these as URLs at runtime; TypeScript type expects string[] + redirect_uris: redirectUriStr ? [redirectUriStr] : [], + grant_types: grantTypes, + response_types: + this.oauthConfig.grantType === 'client_credentials' ? [] : ['code'], + token_endpoint_auth_method: this.oauthConfig.clientSecret + ? 'client_secret_post' + : 'none', + scope: this.oauthConfig.scope, + }; + } + + /** + * Load client information from config or file + * Validates that stored redirect_uris match current redirectUrl + */ + clientInformation(): OAuthClientInformationMixed | undefined { + // First check if client ID is configured statically + if (this.oauthConfig.clientId) { + const info: OAuthClientInformationMixed = { + client_id: this.oauthConfig.clientId, + }; + if (this.oauthConfig.clientSecret) { + info.client_secret = this.oauthConfig.clientSecret; + } + return info; + } + + // Otherwise, try to load from dynamic registration + const stored = readJsonFile< + OAuthClientInformationMixed & { redirect_uris?: string[] } + >(this.paths.client); + + // Validate stored redirect_uris match current redirectUrl + // If they don't match, the server will reject with "Invalid redirect_uri" + if (stored?.redirect_uris && stored.redirect_uris.length > 0) { + const currentRedirectUrl = this.redirectUrl + ? String(this.redirectUrl) + : undefined; + const storedHasCurrentUrl = + currentRedirectUrl && stored.redirect_uris.includes(currentRedirectUrl); + + if (!storedHasCurrentUrl) { + debug( + `Stored client redirect_uris [${stored.redirect_uris.join(', ')}] don't match current [${currentRedirectUrl}] - invalidating client registration`, + ); + this.invalidateCredentials('client'); + return undefined; + } + } + + return stored; + } + + /** + * Save dynamically registered client information + */ + saveClientInformation(clientInformation: OAuthClientInformationMixed): void { + debug(`Saving client information for ${this.serverName}`); + writeJsonFile(this.paths.client, clientInformation); + } + + /** + * Load OAuth tokens + */ + tokens(): OAuthTokens | undefined { + return readJsonFile(this.paths.tokens); + } + + /** + * Save OAuth tokens + */ + saveTokens(tokens: OAuthTokens): void { + debug(`Saving tokens for ${this.serverName}`); + writeJsonFile(this.paths.tokens, tokens); + } + + /** + * Get the captured authorization URL (for non-interactive mode) + */ + get capturedAuthUrl(): string | null { + return this._capturedAuthUrl; + } + + /** + * Redirect to authorization URL + * Captures auth URL for AI agents - never opens browser + * Background callback server is already pre-started + */ + redirectToAuthorization(authorizationUrl: URL): void { + debug(`Capturing auth URL for ${this.serverName} (non-interactive mode)`); + + // Update auth URL with actual redirect_uri (from pre-started server) + const actualRedirectUri = this.redirectUrl; + if (actualRedirectUri) { + authorizationUrl.searchParams.set( + 'redirect_uri', + String(actualRedirectUri), + ); + } + + // Capture the auth URL for inclusion in error message + this._capturedAuthUrl = authorizationUrl.toString(); + debug(`Captured auth URL: ${this._capturedAuthUrl}`); + + // Background server is already pre-started (from preStartCallbackServer) + // It will continue running for the timeout period (default 5 min) + } + + /** + * Wait for the OAuth callback to complete + * Returns the authorization code + */ + async waitForCallback(): Promise { + // Wait for server to finish starting if in progress + if (this.callbackServerStarting) { + await this.callbackServerStarting; + } + + if (!this.callbackServerState) { + // Fallback: start server now if not already started (with port fallback) + const portsToTry = this.getPortsToTry(); + const { state, actualPort } = + await startCallbackServerWithFallback(portsToTry); + this.callbackServerState = state; + this.actualCallbackPort = actualPort; + } + return this.callbackServerState.promise; + } + + /** + * Check if this provider has a pending callback server + */ + hasPendingCallback(): boolean { + return this.callbackServerState !== null; + } + + /** + * Clean up the callback server if running. + * + * Production code does NOT call this - the server self-cleans on: + * - Success: when authorization code is received + * - Error: when OAuth error is returned + * - Timeout: after 5 minute timeout expires + * + * This method exists for tests that start servers without completing OAuth. + */ + cleanupCallbackServer(): void { + if (this.callbackServerState) { + this.callbackServerState.cleanup(); + this.callbackServerState = null; + } + } + + /** + * Save PKCE code verifier + */ + saveCodeVerifier(codeVerifier: string): void { + debug(`Saving code verifier for ${this.serverName}`); + writeTextFile(this.paths.verifier, codeVerifier); + } + + /** + * Load PKCE code verifier + */ + codeVerifier(): string { + const verifier = readTextFile(this.paths.verifier); + if (!verifier) { + throw new Error( + 'No code verifier found - OAuth flow may have been interrupted', + ); + } + return verifier; + } + + /** + * Invalidate stored credentials + */ + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): void { + debug(`Invalidating credentials for ${this.serverName}: ${scope}`); + + switch (scope) { + case 'all': + deleteFile(this.paths.tokens); + deleteFile(this.paths.client); + deleteFile(this.paths.verifier); + break; + case 'client': + deleteFile(this.paths.client); + break; + case 'tokens': + deleteFile(this.paths.tokens); + break; + case 'verifier': + deleteFile(this.paths.verifier); + break; + } + } + + /** + * Prepare token request for client_credentials flow + */ + prepareTokenRequest(scope?: string): URLSearchParams | undefined { + if (this.oauthConfig.grantType !== 'client_credentials') { + return undefined; + } + + const params = new URLSearchParams({ + grant_type: 'client_credentials', + }); + + const requestScope = scope || this.oauthConfig.scope; + if (requestScope) { + params.set('scope', requestScope); + } + + return params; + } + + /** + * Get the callback port for this provider + */ + getCallbackPort(): number { + return ( + this.actualCallbackPort ?? + this.oauthConfig.callbackPort ?? + DEFAULT_CALLBACK_PORT + ); + } + + /** + * Get the list of ports to try for the callback server + * If a specific port is configured, it comes first + * Then falls back to the default port order + */ + getPortsToTry(): number[] { + const ports: number[] = []; + + // If explicit callbackPorts array is configured, use it + if ( + this.oauthConfig.callbackPorts && + this.oauthConfig.callbackPorts.length > 0 + ) { + return [...this.oauthConfig.callbackPorts]; + } + + // If a single callbackPort is configured, try it first + if (this.oauthConfig.callbackPort) { + ports.push(this.oauthConfig.callbackPort); + } + + // Add default fallback ports (excluding any already added) + for (const port of DEFAULT_PORT_FALLBACK_ORDER) { + if (!ports.includes(port)) { + ports.push(port); + } + } + + return ports; + } + + /** + * Pre-start the callback server to determine the actual port + * Call this before accessing redirectUrl to ensure correct port in authorization URL + * Returns the actual port used + */ + async preStartCallbackServer(): Promise { + // Skip for client_credentials flow + if (this.oauthConfig.grantType === 'client_credentials') { + return 0; + } + + // Already started or starting + if (this.actualCallbackPort !== null) { + return this.actualCallbackPort; + } + if (this.callbackServerStarting) { + await this.callbackServerStarting; + // After awaiting, actualCallbackPort is guaranteed to be set + if (this.actualCallbackPort !== null) { + return this.actualCallbackPort; + } + } + + const portsToTry = this.getPortsToTry(); + debug( + `Pre-starting callback server for ${this.serverName}, trying ports: ${portsToTry.join(', ')}`, + ); + + const { state, actualPort } = + await startCallbackServerWithFallback(portsToTry); + this.callbackServerState = state; + this.actualCallbackPort = actualPort; + + debug( + `Callback server pre-started on port ${actualPort} for ${this.serverName}`, + ); + return actualPort; + } +} diff --git a/src/oauth/storage.ts b/src/oauth/storage.ts new file mode 100644 index 0000000..3c496b0 --- /dev/null +++ b/src/oauth/storage.ts @@ -0,0 +1,131 @@ +/** + * OAuth file storage utilities + * + * Handles persistent storage of OAuth tokens, client information, + * and PKCE verifiers with secure file permissions. + */ + +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { debug } from '../config.js'; + +/** + * Get the MCP CLI storage directory + * Can be overridden via MCP_CLI_HOME env var for testing + */ +export function getMcpCliDir(): string { + const baseDir = process.env.MCP_CLI_HOME || homedir(); + return join(baseDir, '.mcp-cli'); +} + +/** + * Get storage directory paths + */ +export function getStoragePaths() { + const mcpCliDir = getMcpCliDir(); + return { + tokens: join(mcpCliDir, 'tokens'), + clients: join(mcpCliDir, 'clients'), + verifiers: join(mcpCliDir, 'verifiers'), + }; +} + +/** + * Ensure a directory exists with secure permissions + */ +export function ensureDir(dir: string): void { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } +} + +/** + * Read JSON file safely + */ +export function readJsonFile(path: string): T | undefined { + try { + if (!existsSync(path)) { + return undefined; + } + const content = readFileSync(path, 'utf-8'); + return JSON.parse(content) as T; + } catch (error) { + debug(`Failed to read ${path}: ${(error as Error).message}`); + return undefined; + } +} + +/** + * Write JSON file safely with secure permissions + */ +export function writeJsonFile(path: string, data: unknown): void { + ensureDir(join(path, '..')); + writeFileSync(path, JSON.stringify(data, null, 2), { mode: 0o600 }); +} + +/** + * Read text file safely + */ +export function readTextFile(path: string): string | undefined { + try { + if (!existsSync(path)) { + return undefined; + } + return readFileSync(path, 'utf-8').trim(); + } catch (error) { + debug(`Failed to read ${path}: ${(error as Error).message}`); + return undefined; + } +} + +/** + * Write text file safely with secure permissions + */ +export function writeTextFile(path: string, content: string): void { + ensureDir(join(path, '..')); + writeFileSync(path, content, { mode: 0o600 }); +} + +/** + * Delete file safely + */ +export function deleteFile(path: string): void { + try { + if (existsSync(path)) { + unlinkSync(path); + } + } catch (error) { + debug(`Failed to delete ${path}: ${(error as Error).message}`); + } +} + +/** + * Sanitize server name for use as filename + */ +export function sanitizeServerName(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +/** + * Get file paths for a server's OAuth data + */ +export function getServerPaths(serverName: string): { + tokens: string; + client: string; + verifier: string; +} { + const safeName = sanitizeServerName(serverName); + const dirs = getStoragePaths(); + return { + tokens: join(dirs.tokens, `${safeName}.json`), + client: join(dirs.clients, `${safeName}.json`), + verifier: join(dirs.verifiers, `${safeName}.txt`), + }; +} diff --git a/src/oauth/types.ts b/src/oauth/types.ts new file mode 100644 index 0000000..ad38a40 --- /dev/null +++ b/src/oauth/types.ts @@ -0,0 +1,40 @@ +/** + * OAuth type definitions + */ + +import type { Server } from 'node:http'; + +/** + * OAuth configuration from server config + */ +export interface OAuthConfig { + grantType?: 'authorization_code' | 'client_credentials'; + clientId?: string; + clientSecret?: string; + scope?: string; + callbackPort?: number; + /** Optional: explicit list of ports to try in order (overrides default fallback) */ + callbackPorts?: number[]; +} + +/** + * Result from OAuth callback + */ +export interface OAuthCallbackResult { + code: string; +} + +/** + * Callback server state for managing the OAuth callback listener + */ +export interface CallbackServerState { + server: Server; + promise: Promise; + cleanup: () => void; +} + +// Default callback port for OAuth redirect +export const DEFAULT_CALLBACK_PORT = 8095; + +// Default port: random (0) - OS assigns unique port for each server, avoiding conflicts +export const DEFAULT_PORT_FALLBACK_ORDER = [0]; diff --git a/tests/config.test.ts b/tests/config.test.ts index a48602c..7931791 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -240,4 +240,159 @@ describe('config', () => { expect(isStdioServer({ url: 'https://example.com' })).toBe(false); }); }); + + describe('OAuth config validation', () => { + test('accepts valid OAuth config with authorization_code', async () => { + const configPath = join(tempDir, 'oauth_auth_code.json'); + await writeFile( + configPath, + JSON.stringify({ + mcpServers: { + test: { + url: 'https://example.com', + oauth: { + grantType: 'authorization_code', + scope: 'read write', + }, + }, + }, + }) + ); + + const config = await loadConfig(configPath); + const server = config.mcpServers.test as any; + expect(server.oauth.grantType).toBe('authorization_code'); + expect(server.oauth.scope).toBe('read write'); + }); + + test('accepts valid OAuth config with client_credentials', async () => { + process.env.TEST_CLIENT_ID = 'test-id'; + process.env.TEST_CLIENT_SECRET = 'test-secret'; + + const configPath = join(tempDir, 'oauth_client_creds.json'); + await writeFile( + configPath, + JSON.stringify({ + mcpServers: { + test: { + url: 'https://example.com', + oauth: { + grantType: 'client_credentials', + clientId: '${TEST_CLIENT_ID}', + clientSecret: '${TEST_CLIENT_SECRET}', + }, + }, + }, + }) + ); + + const config = await loadConfig(configPath); + const server = config.mcpServers.test as any; + expect(server.oauth.grantType).toBe('client_credentials'); + expect(server.oauth.clientId).toBe('test-id'); + expect(server.oauth.clientSecret).toBe('test-secret'); + + delete process.env.TEST_CLIENT_ID; + delete process.env.TEST_CLIENT_SECRET; + }); + + test('throws on invalid grantType', async () => { + const configPath = join(tempDir, 'oauth_bad_grant.json'); + await writeFile( + configPath, + JSON.stringify({ + mcpServers: { + test: { + url: 'https://example.com', + oauth: { + grantType: 'invalid_grant', + }, + }, + }, + }) + ); + + await expect(loadConfig(configPath)).rejects.toThrow('Invalid OAuth grantType'); + }); + + test('throws on client_credentials without clientId', async () => { + const configPath = join(tempDir, 'oauth_no_client_id.json'); + await writeFile( + configPath, + JSON.stringify({ + mcpServers: { + test: { + url: 'https://example.com', + oauth: { + grantType: 'client_credentials', + clientSecret: 'secret', + }, + }, + }, + }) + ); + + await expect(loadConfig(configPath)).rejects.toThrow('requires clientId'); + }); + + test('throws on client_credentials without clientSecret', async () => { + const configPath = join(tempDir, 'oauth_no_client_secret.json'); + await writeFile( + configPath, + JSON.stringify({ + mcpServers: { + test: { + url: 'https://example.com', + oauth: { + grantType: 'client_credentials', + clientId: 'client-id', + }, + }, + }, + }) + ); + + await expect(loadConfig(configPath)).rejects.toThrow('requires clientSecret'); + }); + + test('throws on invalid callbackPort', async () => { + const configPath = join(tempDir, 'oauth_bad_port.json'); + await writeFile( + configPath, + JSON.stringify({ + mcpServers: { + test: { + url: 'https://example.com', + oauth: { + callbackPort: 99999, + }, + }, + }, + }) + ); + + await expect(loadConfig(configPath)).rejects.toThrow('Invalid callbackPort'); + }); + + test('accepts valid callbackPort', async () => { + const configPath = join(tempDir, 'oauth_valid_port.json'); + await writeFile( + configPath, + JSON.stringify({ + mcpServers: { + test: { + url: 'https://example.com', + oauth: { + callbackPort: 9000, + }, + }, + }, + }) + ); + + const config = await loadConfig(configPath); + const server = config.mcpServers.test as any; + expect(server.oauth.callbackPort).toBe(9000); + }); + }); }); diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts new file mode 100644 index 0000000..413c303 --- /dev/null +++ b/tests/oauth.test.ts @@ -0,0 +1,493 @@ +/** + * Unit tests for OAuth module + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { McpCliOAuthProvider, type OAuthConfig } from '../src/oauth/index'; + +describe('oauth', () => { + // Use a unique temp directory for each test run to avoid conflicts + let testDir: string; + const originalMcpCliHome = process.env.MCP_CLI_HOME; + + beforeEach(() => { + // Create unique test directory for each test + testDir = join(tmpdir(), `mcp-cli-oauth-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + // Use MCP_CLI_HOME env var to override storage location + process.env.MCP_CLI_HOME = testDir; + }); + + afterEach(() => { + // Restore original MCP_CLI_HOME + if (originalMcpCliHome === undefined) { + delete process.env.MCP_CLI_HOME; + } else { + process.env.MCP_CLI_HOME = originalMcpCliHome; + } + + // Clean up test directory + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('McpCliOAuthProvider', () => { + describe('constructor and paths', () => { + test('creates storage directories', () => { + const config: OAuthConfig = { scope: 'read' }; + new McpCliOAuthProvider('test-server', 'https://example.com', config); + + expect(existsSync(join(testDir, '.mcp-cli', 'tokens'))).toBe(true); + expect(existsSync(join(testDir, '.mcp-cli', 'clients'))).toBe(true); + expect(existsSync(join(testDir, '.mcp-cli', 'verifiers'))).toBe(true); + }); + + test('sanitizes server names for file paths', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider( + 'my/weird:server.name', + 'https://example.com', + config + ); + + // Save some data to verify path sanitization + provider.saveTokens({ + access_token: 'test', + token_type: 'Bearer', + }); + + // Check that a sanitized filename was used + const tokensDir = join(testDir, '.mcp-cli', 'tokens'); + const files = require('fs').readdirSync(tokensDir); + expect(files.length).toBe(1); + expect(files[0]).toBe('my_weird_server_name.json'); + }); + }); + + describe('redirectUrl', () => { + test('returns callback URL for authorization_code flow', () => { + const config: OAuthConfig = { grantType: 'authorization_code' }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.redirectUrl).toBe('http://localhost:8095/callback'); + }); + + test('returns callback URL when grantType is not specified (default)', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.redirectUrl).toBe('http://localhost:8095/callback'); + }); + + test('returns undefined for client_credentials flow', () => { + const config: OAuthConfig = { + grantType: 'client_credentials', + clientId: 'id', + clientSecret: 'secret', + }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.redirectUrl).toBeUndefined(); + }); + + test('uses custom callback port', () => { + const config: OAuthConfig = { callbackPort: 9999 }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.redirectUrl).toBe('http://localhost:9999/callback'); + }); + }); + + describe('clientMetadata', () => { + test('returns correct metadata for authorization_code flow', () => { + const config: OAuthConfig = { scope: 'tools:read' }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const metadata = provider.clientMetadata; + expect(metadata.client_name).toBe('mcp-cli (test)'); + expect(metadata.grant_types).toContain('authorization_code'); + expect(metadata.grant_types).toContain('refresh_token'); + expect(metadata.response_types).toContain('code'); + expect(metadata.scope).toBe('tools:read'); + }); + + test('returns correct metadata for client_credentials flow', () => { + const config: OAuthConfig = { + grantType: 'client_credentials', + clientId: 'id', + clientSecret: 'secret', + }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const metadata = provider.clientMetadata; + expect(metadata.grant_types).toEqual(['client_credentials']); + expect(metadata.response_types).toEqual([]); + expect(metadata.redirect_uris).toEqual([]); + }); + + test('sets token_endpoint_auth_method based on clientSecret', () => { + const withSecret: OAuthConfig = { clientId: 'id', clientSecret: 'secret' }; + const providerWithSecret = new McpCliOAuthProvider('test', 'https://example.com', withSecret); + expect(providerWithSecret.clientMetadata.token_endpoint_auth_method).toBe('client_secret_post'); + + const withoutSecret: OAuthConfig = { clientId: 'id' }; + const providerWithoutSecret = new McpCliOAuthProvider('test', 'https://example.com', withoutSecret); + expect(providerWithoutSecret.clientMetadata.token_endpoint_auth_method).toBe('none'); + }); + }); + + describe('clientInformation', () => { + test('returns static client info from config', () => { + const config: OAuthConfig = { clientId: 'my-client', clientSecret: 'my-secret' }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const info = provider.clientInformation(); + expect(info?.client_id).toBe('my-client'); + expect(info?.client_secret).toBe('my-secret'); + }); + + test('returns undefined when no client info configured or saved', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.clientInformation()).toBeUndefined(); + }); + + test('loads saved client info from file', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + // Save client info + provider.saveClientInformation({ + client_id: 'dynamic-client', + client_secret: 'dynamic-secret', + }); + + // Should load from file + const info = provider.clientInformation(); + expect(info?.client_id).toBe('dynamic-client'); + expect(info?.client_secret).toBe('dynamic-secret'); + }); + + test('prefers static config over saved file', () => { + const config: OAuthConfig = { clientId: 'static-id' }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + // Save different client info + provider.saveClientInformation({ client_id: 'saved-id' }); + + // Should return static config + const info = provider.clientInformation(); + expect(info?.client_id).toBe('static-id'); + }); + + test('invalidates client when stored redirect_uris do not match current redirectUrl', () => { + const config: OAuthConfig = { callbackPort: 3000 }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + // Save client info with different redirect_uri (e.g., from previous registration on port 8080) + const clientPath = join(testDir, '.mcp-cli', 'clients', 'test.json'); + const storedClient = { + client_id: 'old-client', + redirect_uris: ['http://localhost:8080/callback'], + }; + writeFileSync(clientPath, JSON.stringify(storedClient)); + + // Should return undefined because redirect_uris don't match + // (current redirectUrl is http://localhost:3000/callback but stored has 8080) + const info = provider.clientInformation(); + expect(info).toBeUndefined(); + + // Client file should be deleted + expect(existsSync(clientPath)).toBe(false); + }); + + test('returns client when stored redirect_uris match current redirectUrl', () => { + const config: OAuthConfig = { callbackPort: 3000 }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + // Save client info with matching redirect_uri + const clientPath = join(testDir, '.mcp-cli', 'clients', 'test.json'); + const storedClient = { + client_id: 'matching-client', + redirect_uris: ['http://localhost:3000/callback'], + }; + writeFileSync(clientPath, JSON.stringify(storedClient)); + + // Should return the client since redirect_uris match + const info = provider.clientInformation(); + expect(info?.client_id).toBe('matching-client'); + }); + }); + + describe('tokens', () => { + test('returns undefined when no tokens saved', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.tokens()).toBeUndefined(); + }); + + test('saves and loads tokens', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const tokens = { + access_token: 'test-access-token', + token_type: 'Bearer', + refresh_token: 'test-refresh-token', + expires_in: 3600, + }; + + provider.saveTokens(tokens); + + const loaded = provider.tokens(); + expect(loaded?.access_token).toBe('test-access-token'); + expect(loaded?.refresh_token).toBe('test-refresh-token'); + }); + + test('tokens are persisted to file', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + provider.saveTokens({ + access_token: 'persisted-token', + token_type: 'Bearer', + }); + + // Create new provider instance + const provider2 = new McpCliOAuthProvider('test', 'https://example.com', config); + const loaded = provider2.tokens(); + expect(loaded?.access_token).toBe('persisted-token'); + }); + }); + + describe('codeVerifier', () => { + test('throws when no verifier saved', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(() => provider.codeVerifier()).toThrow('No code verifier found'); + }); + + test('saves and loads code verifier', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + provider.saveCodeVerifier('test-verifier-123'); + expect(provider.codeVerifier()).toBe('test-verifier-123'); + }); + }); + + describe('invalidateCredentials', () => { + test('invalidates all credentials', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + // Save various credentials + provider.saveTokens({ access_token: 'token', token_type: 'Bearer' }); + provider.saveClientInformation({ client_id: 'client' }); + provider.saveCodeVerifier('verifier'); + + // Verify they exist + expect(provider.tokens()).toBeDefined(); + + // Invalidate all + provider.invalidateCredentials('all'); + + // Verify they're gone + expect(provider.tokens()).toBeUndefined(); + expect(() => provider.codeVerifier()).toThrow(); + }); + + test('invalidates only tokens', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + provider.saveTokens({ access_token: 'token', token_type: 'Bearer' }); + provider.saveCodeVerifier('verifier'); + + provider.invalidateCredentials('tokens'); + + expect(provider.tokens()).toBeUndefined(); + expect(provider.codeVerifier()).toBe('verifier'); // Still exists + }); + + test('invalidates only verifier', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + provider.saveTokens({ access_token: 'token', token_type: 'Bearer' }); + provider.saveCodeVerifier('verifier'); + + provider.invalidateCredentials('verifier'); + + expect(provider.tokens()).toBeDefined(); // Still exists + expect(() => provider.codeVerifier()).toThrow(); + }); + }); + + describe('prepareTokenRequest', () => { + test('returns undefined for authorization_code flow', () => { + const config: OAuthConfig = { grantType: 'authorization_code' }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.prepareTokenRequest()).toBeUndefined(); + }); + + test('returns params for client_credentials flow', () => { + const config: OAuthConfig = { + grantType: 'client_credentials', + clientId: 'id', + clientSecret: 'secret', + scope: 'read write', + }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const params = provider.prepareTokenRequest(); + expect(params?.get('grant_type')).toBe('client_credentials'); + expect(params?.get('scope')).toBe('read write'); + }); + + test('allows scope override', () => { + const config: OAuthConfig = { + grantType: 'client_credentials', + clientId: 'id', + clientSecret: 'secret', + scope: 'default-scope', + }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const params = provider.prepareTokenRequest('override-scope'); + expect(params?.get('scope')).toBe('override-scope'); + }); + }); + + describe('getCallbackPort', () => { + test('returns default port', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.getCallbackPort()).toBe(8095); + }); + + test('returns custom port', () => { + const config: OAuthConfig = { callbackPort: 12345 }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.getCallbackPort()).toBe(12345); + }); + }); + + describe('getPortsToTry', () => { + test('returns default port fallback order when no config', () => { + const config: OAuthConfig = {}; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const ports = provider.getPortsToTry(); + // Default: always use random port (0) - OS assigns unique port to avoid conflicts + expect(ports).toEqual([0]); + }); + + test('puts configured callbackPort first with random fallback', () => { + const config: OAuthConfig = { callbackPort: 9000 }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const ports = provider.getPortsToTry(); + // Configured port first, then random (0) as fallback + expect(ports).toEqual([9000, 0]); + }); + + test('uses callbackPorts array when configured', () => { + const config: OAuthConfig = { callbackPorts: [3000, 3001, 3002] }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const ports = provider.getPortsToTry(); + expect(ports).toEqual([3000, 3001, 3002]); + }); + + test('callbackPorts overrides callbackPort and defaults', () => { + const config: OAuthConfig = { callbackPort: 9000, callbackPorts: [4000, 4001] }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const ports = provider.getPortsToTry(); + // callbackPorts takes precedence + expect(ports).toEqual([4000, 4001]); + }); + }); + + describe('redirectUrl with port 80', () => { + test('omits port from URL when port is 80', () => { + const config: OAuthConfig = { callbackPort: 80 }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.redirectUrl).toBe('http://localhost/callback'); + }); + + test('includes port in URL for non-80 ports', () => { + const config: OAuthConfig = { callbackPort: 8080 }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + expect(provider.redirectUrl).toBe('http://localhost:8080/callback'); + }); + }); + + describe('preStartCallbackServer', () => { + test('returns 0 for client_credentials flow', async () => { + const config: OAuthConfig = { + grantType: 'client_credentials', + clientId: 'id', + clientSecret: 'secret', + }; + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + const port = await provider.preStartCallbackServer(); + expect(port).toBe(0); + }); + + test('starts server and returns actual port', async () => { + const config: OAuthConfig = { callbackPorts: [0] }; // Use random port for test + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + try { + const port = await provider.preStartCallbackServer(); + expect(port).toBeGreaterThan(0); + expect(provider.getCallbackPort()).toBe(port); + } finally { + provider.cleanupCallbackServer(); + } + }); + + test('returns same port on subsequent calls', async () => { + const config: OAuthConfig = { callbackPorts: [0] }; // Use random port for test + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + try { + const port1 = await provider.preStartCallbackServer(); + const port2 = await provider.preStartCallbackServer(); + expect(port1).toBe(port2); + } finally { + provider.cleanupCallbackServer(); + } + }); + + test('redirectUrl uses actual port after pre-start', async () => { + const config: OAuthConfig = { callbackPorts: [0] }; // Use random port + const provider = new McpCliOAuthProvider('test', 'https://example.com', config); + + try { + const port = await provider.preStartCallbackServer(); + expect(provider.redirectUrl).toBe(`http://localhost:${port}/callback`); + } finally { + provider.cleanupCallbackServer(); + } + }); + }); + }); +});