From 4d653c833ac1371939876e03407471248263393e Mon Sep 17 00:00:00 2001 From: Brian Ray <62354532+emeryray2002@users.noreply.github.com> Date: Tue, 22 Jul 2025 10:05:36 -0400 Subject: [PATCH] MCP OAuth Part 3 - CLI/UI/Documentation (#4319) Co-authored-by: Greg Shikhman --- docs/tools/mcp-server.md | 84 ++++++++ .../cli/src/ui/commands/mcpCommand.test.ts | 166 ++++++++++++++++ packages/cli/src/ui/commands/mcpCommand.ts | 185 +++++++++++++++++- 3 files changed, 431 insertions(+), 4 deletions(-) diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 96c837d4..187bd370 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -95,6 +95,90 @@ Each server configuration supports the following properties: - **`includeTools`** (string[]): List of tool names to include from this MCP server. When specified, only the tools listed here will be available from this server (whitelist behavior). If not specified, all tools from the server are enabled by default. - **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are exposed by the server. **Note:** `excludeTools` takes precedence over `includeTools` - if a tool is in both lists, it will be excluded. +### OAuth Support for Remote MCP Servers + +The Gemini CLI supports OAuth 2.0 authentication for remote MCP servers using SSE or HTTP transports. This enables secure access to MCP servers that require authentication. + +#### Automatic OAuth Discovery + +For servers that support OAuth discovery, you can omit the OAuth configuration and let the CLI discover it automatically: + +```json +{ + "mcpServers": { + "discoveredServer": { + "url": "https://api.example.com/sse" + } + } +} +``` + +The CLI will automatically: + +- Detect when a server requires OAuth authentication (401 responses) +- Discover OAuth endpoints from server metadata +- Perform dynamic client registration if supported +- Handle the OAuth flow and token management + +#### Authentication Flow + +When connecting to an OAuth-enabled server: + +1. **Initial connection attempt** fails with 401 Unauthorized +2. **OAuth discovery** finds authorization and token endpoints +3. **Browser opens** for user authentication (requires local browser access) +4. **Authorization code** is exchanged for access tokens +5. **Tokens are stored** securely for future use +6. **Connection retry** succeeds with valid tokens + +#### Browser Redirect Requirements + +**Important:** OAuth authentication requires that your local machine can: + +- Open a web browser for authentication +- Receive redirects on `http://localhost:7777/oauth/callback` + +This feature will not work in: + +- Headless environments without browser access +- Remote SSH sessions without X11 forwarding +- Containerized environments without browser support + +#### Managing OAuth Authentication + +Use the `/mcp auth` command to manage OAuth authentication: + +```bash +# List servers requiring authentication +/mcp auth + +# Authenticate with a specific server +/mcp auth serverName + +# Re-authenticate if tokens expire +/mcp auth serverName +``` + +#### OAuth Configuration Properties + +- **`enabled`** (boolean): Enable OAuth for this server +- **`clientId`** (string): OAuth client identifier (optional with dynamic registration) +- **`clientSecret`** (string): OAuth client secret (optional for public clients) +- **`authorizationUrl`** (string): OAuth authorization endpoint (auto-discovered if omitted) +- **`tokenUrl`** (string): OAuth token endpoint (auto-discovered if omitted) +- **`scopes`** (string[]): Required OAuth scopes +- **`redirectUri`** (string): Custom redirect URI (defaults to `http://localhost:7777/oauth/callback`) +- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs + +#### Token Management + +OAuth tokens are automatically: + +- **Stored securely** in `~/.gemini/mcp-oauth-tokens.json` +- **Refreshed** when expired (if refresh tokens are available) +- **Validated** before each connection attempt +- **Cleaned up** when invalid or expired + ### Example Configurations #### Python MCP Server (Stdio) diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index f23cf3ab..e52cb9df 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -30,6 +30,13 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { ...actual, getMCPServerStatus: vi.fn(), getMCPDiscoveryState: vi.fn(), + MCPOAuthProvider: { + authenticate: vi.fn(), + }, + MCPOAuthTokenStorage: { + getToken: vi.fn(), + isTokenExpired: vi.fn(), + }, }; }); @@ -810,4 +817,163 @@ describe('mcpCommand', () => { } }); }); + + describe('auth subcommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should list OAuth-enabled servers when no server name is provided', async () => { + const context = createMockCommandContext({ + services: { + config: { + getMcpServers: vi.fn().mockReturnValue({ + 'oauth-server': { oauth: { enabled: true } }, + 'regular-server': {}, + 'another-oauth': { oauth: { enabled: true } }, + }), + }, + }, + }); + + const authCommand = mcpCommand.subCommands?.find( + (cmd) => cmd.name === 'auth', + ); + expect(authCommand).toBeDefined(); + + const result = await authCommand!.action!(context, ''); + expect(isMessageAction(result)).toBe(true); + if (isMessageAction(result)) { + expect(result.messageType).toBe('info'); + expect(result.content).toContain('oauth-server'); + expect(result.content).toContain('another-oauth'); + expect(result.content).not.toContain('regular-server'); + expect(result.content).toContain('/mcp auth '); + } + }); + + it('should show message when no OAuth servers are configured', async () => { + const context = createMockCommandContext({ + services: { + config: { + getMcpServers: vi.fn().mockReturnValue({ + 'regular-server': {}, + }), + }, + }, + }); + + const authCommand = mcpCommand.subCommands?.find( + (cmd) => cmd.name === 'auth', + ); + const result = await authCommand!.action!(context, ''); + + expect(isMessageAction(result)).toBe(true); + if (isMessageAction(result)) { + expect(result.messageType).toBe('info'); + expect(result.content).toBe( + 'No MCP servers configured with OAuth authentication.', + ); + } + }); + + it('should authenticate with a specific server', async () => { + const mockToolRegistry = { + discoverToolsForServer: vi.fn(), + }; + const mockGeminiClient = { + setTools: vi.fn(), + }; + + const context = createMockCommandContext({ + services: { + config: { + getMcpServers: vi.fn().mockReturnValue({ + 'test-server': { + url: 'http://localhost:3000', + oauth: { enabled: true }, + }, + }), + getToolRegistry: vi.fn().mockResolvedValue(mockToolRegistry), + getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + }, + }, + }); + + const { MCPOAuthProvider } = await import('@google/gemini-cli-core'); + + const authCommand = mcpCommand.subCommands?.find( + (cmd) => cmd.name === 'auth', + ); + const result = await authCommand!.action!(context, 'test-server'); + + expect(MCPOAuthProvider.authenticate).toHaveBeenCalledWith( + 'test-server', + { enabled: true }, + 'http://localhost:3000', + ); + expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith( + 'test-server', + ); + expect(mockGeminiClient.setTools).toHaveBeenCalled(); + + expect(isMessageAction(result)).toBe(true); + if (isMessageAction(result)) { + expect(result.messageType).toBe('info'); + expect(result.content).toContain('Successfully authenticated'); + } + }); + + it('should handle authentication errors', async () => { + const context = createMockCommandContext({ + services: { + config: { + getMcpServers: vi.fn().mockReturnValue({ + 'test-server': { oauth: { enabled: true } }, + }), + }, + }, + }); + + const { MCPOAuthProvider } = await import('@google/gemini-cli-core'); + ( + MCPOAuthProvider.authenticate as ReturnType + ).mockRejectedValue(new Error('Auth failed')); + + const authCommand = mcpCommand.subCommands?.find( + (cmd) => cmd.name === 'auth', + ); + const result = await authCommand!.action!(context, 'test-server'); + + expect(isMessageAction(result)).toBe(true); + if (isMessageAction(result)) { + expect(result.messageType).toBe('error'); + expect(result.content).toContain('Failed to authenticate'); + expect(result.content).toContain('Auth failed'); + } + }); + + it('should handle non-existent server', async () => { + const context = createMockCommandContext({ + services: { + config: { + getMcpServers: vi.fn().mockReturnValue({ + 'existing-server': {}, + }), + }, + }, + }); + + const authCommand = mcpCommand.subCommands?.find( + (cmd) => cmd.name === 'auth', + ); + const result = await authCommand!.action!(context, 'non-existent'); + + expect(isMessageAction(result)).toBe(true); + if (isMessageAction(result)) { + expect(result.messageType).toBe('error'); + expect(result.content).toContain("MCP server 'non-existent' not found"); + } + }); + }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 373f1ca5..c33a25d1 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -9,6 +9,7 @@ import { SlashCommandActionReturn, CommandContext, CommandKind, + MessageActionReturn, } from './types.js'; import { DiscoveredMCPTool, @@ -16,12 +17,16 @@ import { getMCPServerStatus, MCPDiscoveryState, MCPServerStatus, + mcpServerRequiresOAuth, + getErrorMessage, } from '@google/gemini-cli-core'; import open from 'open'; const COLOR_GREEN = '\u001b[32m'; const COLOR_YELLOW = '\u001b[33m'; +const COLOR_RED = '\u001b[31m'; const COLOR_CYAN = '\u001b[36m'; +const COLOR_GREY = '\u001b[90m'; const RESET_COLOR = '\u001b[0m'; const getMcpStatus = async ( @@ -128,6 +133,31 @@ const getMcpStatus = async ( // Format server header with bold formatting and status message += `${statusIndicator} \u001b[1m${serverDisplayName}\u001b[0m - ${statusText}`; + let needsAuthHint = mcpServerRequiresOAuth.get(serverName) || false; + // Add OAuth status if applicable + if (server?.oauth?.enabled) { + needsAuthHint = true; + try { + const { MCPOAuthTokenStorage } = await import( + '@google/gemini-cli-core' + ); + const hasToken = await MCPOAuthTokenStorage.getToken(serverName); + if (hasToken) { + const isExpired = MCPOAuthTokenStorage.isTokenExpired(hasToken.token); + if (isExpired) { + message += ` ${COLOR_YELLOW}(OAuth token expired)${RESET_COLOR}`; + } else { + message += ` ${COLOR_GREEN}(OAuth authenticated)${RESET_COLOR}`; + needsAuthHint = false; + } + } else { + message += ` ${COLOR_RED}(OAuth not authenticated)${RESET_COLOR}`; + } + } catch (_err) { + // If we can't check OAuth status, just continue + } + } + // Add tool count with conditional messaging if (status === MCPServerStatus.CONNECTED) { message += ` (${serverTools.length} tools)`; @@ -193,7 +223,11 @@ const getMcpStatus = async ( } }); } else { - message += ' No tools available\n'; + message += ' No tools available'; + if (status === MCPServerStatus.DISCONNECTED && needsAuthHint) { + message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`; + } + message += '\n'; } message += '\n'; } @@ -213,6 +247,7 @@ const getMcpStatus = async ( message += ` • Use ${COLOR_CYAN}/mcp desc${RESET_COLOR} to show server and tool descriptions\n`; message += ` • Use ${COLOR_CYAN}/mcp schema${RESET_COLOR} to show tool parameter schemas\n`; message += ` • Use ${COLOR_CYAN}/mcp nodesc${RESET_COLOR} to hide descriptions\n`; + message += ` • Use ${COLOR_CYAN}/mcp auth ${RESET_COLOR} to authenticate with OAuth-enabled servers\n`; message += ` • Press ${COLOR_CYAN}Ctrl+T${RESET_COLOR} to toggle tool descriptions on/off\n`; message += '\n'; } @@ -227,9 +262,139 @@ const getMcpStatus = async ( }; }; -export const mcpCommand: SlashCommand = { - name: 'mcp', - description: 'list configured MCP servers and tools', +const authCommand: SlashCommand = { + name: 'auth', + description: 'Authenticate with an OAuth-enabled MCP server', + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const serverName = args.trim(); + const { config } = context.services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const mcpServers = config.getMcpServers() || {}; + + if (!serverName) { + // List servers that support OAuth + const oauthServers = Object.entries(mcpServers) + .filter(([_, server]) => server.oauth?.enabled) + .map(([name, _]) => name); + + if (oauthServers.length === 0) { + return { + type: 'message', + messageType: 'info', + content: 'No MCP servers configured with OAuth authentication.', + }; + } + + return { + type: 'message', + messageType: 'info', + content: `MCP servers with OAuth authentication:\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth to authenticate.`, + }; + } + + const server = mcpServers[serverName]; + if (!server) { + return { + type: 'message', + messageType: 'error', + content: `MCP server '${serverName}' not found.`, + }; + } + + // Always attempt OAuth authentication, even if not explicitly configured + // The authentication process will discover OAuth requirements automatically + + try { + context.ui.addItem( + { + type: 'info', + text: `Starting OAuth authentication for MCP server '${serverName}'...`, + }, + Date.now(), + ); + + // Import dynamically to avoid circular dependencies + const { MCPOAuthProvider } = await import('@google/gemini-cli-core'); + + // Create OAuth config for authentication (will be discovered automatically) + const oauthConfig = server.oauth || { + authorizationUrl: '', // Will be discovered automatically + tokenUrl: '', // Will be discovered automatically + }; + + // Pass the MCP server URL for OAuth discovery + const mcpServerUrl = server.httpUrl || server.url; + await MCPOAuthProvider.authenticate( + serverName, + oauthConfig, + mcpServerUrl, + ); + + context.ui.addItem( + { + type: 'info', + text: `✅ Successfully authenticated with MCP server '${serverName}'!`, + }, + Date.now(), + ); + + // Trigger tool re-discovery to pick up authenticated server + const toolRegistry = await config.getToolRegistry(); + if (toolRegistry) { + context.ui.addItem( + { + type: 'info', + text: `Re-discovering tools from '${serverName}'...`, + }, + Date.now(), + ); + await toolRegistry.discoverToolsForServer(serverName); + } + // Update the client with the new tools + const geminiClient = config.getGeminiClient(); + if (geminiClient) { + await geminiClient.setTools(); + } + + return { + type: 'message', + messageType: 'info', + content: `Successfully authenticated and refreshed tools for '${serverName}'.`, + }; + } catch (error) { + return { + type: 'message', + messageType: 'error', + content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`, + }; + } + }, + completion: async (context: CommandContext, partialArg: string) => { + const { config } = context.services; + if (!config) return []; + + const mcpServers = config.getMcpServers() || {}; + return Object.keys(mcpServers).filter((name) => + name.startsWith(partialArg), + ); + }, +}; + +const listCommand: SlashCommand = { + name: 'list', + description: 'List configured MCP servers and tools', kind: CommandKind.BUILT_IN, action: async (context: CommandContext, args: string) => { const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean); @@ -251,3 +416,15 @@ export const mcpCommand: SlashCommand = { return getMcpStatus(context, showDescriptions, showSchema, showTips); }, }; + +export const mcpCommand: SlashCommand = { + name: 'mcp', + description: + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers', + kind: CommandKind.BUILT_IN, + subCommands: [listCommand, authCommand], + // Default action when no subcommand is provided + action: async (context: CommandContext, args: string) => + // If no subcommand, run the list command + listCommand.action!(context, args), +};