diff options
Diffstat (limited to 'packages/cli/src')
| -rw-r--r-- | packages/cli/src/ui/commands/mcpCommand.test.ts | 166 | ||||
| -rw-r--r-- | packages/cli/src/ui/commands/mcpCommand.ts | 185 |
2 files changed, 347 insertions, 4 deletions
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 <server-name>'); + } + }); + + 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<typeof vi.fn> + ).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 <server-name>${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<MessageActionReturn> => { + 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 <server-name> 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), +}; |
