From d37fff7fd60fd1e9b69f487d5f23b1121792d331 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Mon, 29 Sep 2025 14:27:06 -0700 Subject: [PATCH] Fix `/tool` and `/mcp` commands to not write terminal escape codes directly (#10010) --- .../cli/src/ui/commands/mcpCommand.test.ts | 920 +----------------- packages/cli/src/ui/commands/mcpCommand.ts | 400 ++------ .../cli/src/ui/commands/toolsCommand.test.ts | 29 +- packages/cli/src/ui/commands/toolsCommand.ts | 38 +- .../src/ui/components/HistoryItemDisplay.tsx | 13 + .../ui/components/views/McpStatus.test.tsx | 163 ++++ .../cli/src/ui/components/views/McpStatus.tsx | 281 ++++++ .../ui/components/views/ToolsList.test.tsx | 62 ++ .../cli/src/ui/components/views/ToolsList.tsx | 52 + .../__snapshots__/McpStatus.test.tsx.snap | 166 ++++ .../__snapshots__/ToolsList.test.tsx.snap | 32 + packages/cli/src/ui/types.ts | 54 +- 12 files changed, 985 insertions(+), 1225 deletions(-) create mode 100644 packages/cli/src/ui/components/views/McpStatus.test.tsx create mode 100644 packages/cli/src/ui/components/views/McpStatus.tsx create mode 100644 packages/cli/src/ui/components/views/ToolsList.test.tsx create mode 100644 packages/cli/src/ui/components/views/ToolsList.tsx create mode 100644 packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap create mode 100644 packages/cli/src/ui/components/views/__snapshots__/ToolsList.test.tsx.snap diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 857ed71545..1070066f0f 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -15,9 +15,9 @@ import { DiscoveredMCPTool, } from '@google/gemini-cli-core'; -import type { MessageActionReturn } from './types.js'; import type { CallableTool } from '@google/genai'; import { Type } from '@google/genai'; +import { MessageType } from '../types.js'; vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -37,13 +37,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); -// Helper function to check if result is a message action -const isMessageAction = (result: unknown): result is MessageActionReturn => - result !== null && - typeof result === 'object' && - 'type' in result && - result.type === 'message'; - // Helper function to create a mock DiscoveredMCPTool const createMockMCPTool = ( name: string, @@ -59,7 +52,6 @@ const createMockMCPTool = ( name, description || `Description for ${name}`, { type: Type.OBJECT, properties: {} }, - name, // serverToolName same as name for simplicity ); describe('mcpCommand', () => { @@ -69,6 +61,7 @@ describe('mcpCommand', () => { getMcpServers: ReturnType; getBlockedMcpServers: ReturnType; getPromptRegistry: ReturnType; + getGeminiClient: ReturnType; }; beforeEach(() => { @@ -94,6 +87,7 @@ describe('mcpCommand', () => { getAllPrompts: vi.fn().mockReturnValue([]), getPromptsByServer: vi.fn().mockReturnValue([]), }), + getGeminiClient: vi.fn(), }; mockContext = createMockCommandContext({ @@ -133,26 +127,6 @@ describe('mcpCommand', () => { }); }); - describe('no MCP servers configured', () => { - beforeEach(() => { - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue([]), - }); - mockConfig.getMcpServers = vi.fn().mockReturnValue({}); - }); - - it('should display a message with a URL when no MCP servers are configured', async () => { - const result = await mcpCommand.action!(mockContext, ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: - 'No MCP servers configured. Please view MCP documentation in your browser: https://goo.gle/gemini-cli-docs-mcp or use the cli /docs command', - }); - }); - }); - describe('with configured MCP servers', () => { beforeEach(() => { const mockMcpServers = { @@ -190,873 +164,47 @@ describe('mcpCommand', () => { getAllTools: vi.fn().mockReturnValue(allTools), }); - const result = await mcpCommand.action!(mockContext, ''); + await mcpCommand.action!(mockContext, ''); - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: expect.stringContaining('Configured MCP servers:'), - }); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - // Server 1 - Connected - expect(message).toContain( - '🟢 \u001b[1mserver1\u001b[0m - Ready (2 tools)', - ); - expect(message).toContain('server1_tool1'); - expect(message).toContain('server1_tool2'); - - // Server 2 - Connected - expect(message).toContain( - '🟢 \u001b[1mserver2\u001b[0m - Ready (1 tool)', - ); - expect(message).toContain('server2_tool1'); - - // Server 3 - Disconnected but with cached tools, so shows as Ready - expect(message).toContain( - '🟢 \u001b[1mserver3\u001b[0m - Ready (1 tool)', - ); - expect(message).toContain('server3_tool1'); - - // Check that helpful tips are displayed when no arguments are provided - expect(message).toContain('💡 Tips:'); - expect(message).toContain('/mcp desc'); - expect(message).toContain('/mcp schema'); - expect(message).toContain('/mcp nodesc'); - expect(message).toContain('Ctrl+T'); - } + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + tools: allTools.map((tool) => ({ + serverName: tool.serverName, + name: tool.name, + description: tool.description, + schema: tool.schema, + })), + showTips: true, + }), + expect.any(Number), + ); }); it('should display tool descriptions when desc argument is used', async () => { - const mockMcpServers = { - server1: { - command: 'cmd1', - description: 'This is a server description', - }, - }; + await mcpCommand.action!(mockContext, 'desc'); - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - - // Mock tools with descriptions using actual DiscoveredMCPTool instances - const mockServerTools = [ - createMockMCPTool('tool1', 'server1', 'This is tool 1 description'), - createMockMCPTool('tool2', 'server1', 'This is tool 2 description'), - ]; - - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue(mockServerTools), - }); - - const result = await mcpCommand.action!(mockContext, 'desc'); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: expect.stringContaining('Configured MCP servers:'), - }); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - - // Check that server description is included - expect(message).toContain( - '\u001b[1mserver1\u001b[0m - Ready (2 tools)', - ); - expect(message).toContain( - '\u001b[32mThis is a server description\u001b[0m', - ); - - // Check that tool descriptions are included - expect(message).toContain('\u001b[36mtool1\u001b[0m'); - expect(message).toContain( - '\u001b[32mThis is tool 1 description\u001b[0m', - ); - expect(message).toContain('\u001b[36mtool2\u001b[0m'); - expect(message).toContain( - '\u001b[32mThis is tool 2 description\u001b[0m', - ); - - // Check that tips are NOT displayed when arguments are provided - expect(message).not.toContain('💡 Tips:'); - } + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + showDescriptions: true, + showTips: false, + }), + expect.any(Number), + ); }); it('should not display descriptions when nodesc argument is used', async () => { - const mockMcpServers = { - server1: { - command: 'cmd1', - description: 'This is a server description', - }, - }; - - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - - const mockServerTools = [ - createMockMCPTool('tool1', 'server1', 'This is tool 1 description'), - ]; - - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue(mockServerTools), - }); - - const result = await mcpCommand.action!(mockContext, 'nodesc'); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: expect.stringContaining('Configured MCP servers:'), - }); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - - // Check that descriptions are not included - expect(message).not.toContain('This is a server description'); - expect(message).not.toContain('This is tool 1 description'); - expect(message).toContain('\u001b[36mtool1\u001b[0m'); - - // Check that tips are NOT displayed when arguments are provided - expect(message).not.toContain('💡 Tips:'); - } - }); - - it('should indicate when a server has no tools', async () => { - const mockMcpServers = { - server1: { command: 'cmd1' }, - server2: { command: 'cmd2' }, - }; - - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - - // Setup server statuses - vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { - if (serverName === 'server1') return MCPServerStatus.CONNECTED; - if (serverName === 'server2') return MCPServerStatus.DISCONNECTED; - return MCPServerStatus.DISCONNECTED; - }); - - // Mock tools - only server1 has tools - const mockServerTools = [createMockMCPTool('server1_tool1', 'server1')]; - - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue(mockServerTools), - }); - - const result = await mcpCommand.action!(mockContext, ''); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain( - '🟢 \u001b[1mserver1\u001b[0m - Ready (1 tool)', - ); - expect(message).toContain('\u001b[36mserver1_tool1\u001b[0m'); - expect(message).toContain( - '🔴 \u001b[1mserver2\u001b[0m - Disconnected (0 tools cached)', - ); - expect(message).toContain('No tools or prompts available'); - } - }); - - it('should show startup indicator when servers are connecting', async () => { - const mockMcpServers = { - server1: { command: 'cmd1' }, - server2: { command: 'cmd2' }, - }; - - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - - // Setup server statuses with one connecting - vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { - if (serverName === 'server1') return MCPServerStatus.CONNECTED; - if (serverName === 'server2') return MCPServerStatus.CONNECTING; - return MCPServerStatus.DISCONNECTED; - }); - - // Setup discovery state as in progress - vi.mocked(getMCPDiscoveryState).mockReturnValue( - MCPDiscoveryState.IN_PROGRESS, - ); - - // Mock tools - const mockServerTools = [ - createMockMCPTool('server1_tool1', 'server1'), - createMockMCPTool('server2_tool1', 'server2'), - ]; - - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue(mockServerTools), - }); - - const result = await mcpCommand.action!(mockContext, ''); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - - // Check that startup indicator is shown - expect(message).toContain( - '⏳ MCP servers are starting up (1 initializing)...', - ); - expect(message).toContain( - 'Note: First startup may take longer. Tool availability will update automatically.', - ); - - // Check server statuses - expect(message).toContain( - '🟢 \u001b[1mserver1\u001b[0m - Ready (1 tool)', - ); - expect(message).toContain( - '🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools and prompts will appear when ready)', - ); - } - }); - - it('should display the extension name for servers from extensions', async () => { - const mockMcpServers = { - server1: { command: 'cmd1', extensionName: 'my-extension' }, - }; - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - - const result = await mcpCommand.action!(mockContext, ''); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('server1 (from my-extension)'); - } - }); - - it('should display blocked MCP servers', async () => { - mockConfig.getMcpServers = vi.fn().mockReturnValue({}); - const blockedServers = [ - { name: 'blocked-server', extensionName: 'my-extension' }, - ]; - mockConfig.getBlockedMcpServers = vi.fn().mockReturnValue(blockedServers); - - const result = await mcpCommand.action!(mockContext, ''); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain( - '🔴 \u001b[1mblocked-server (from my-extension)\u001b[0m - Blocked', - ); - } - }); - - it('should display both active and blocked servers correctly', async () => { - const mockMcpServers = { - server1: { command: 'cmd1', extensionName: 'my-extension' }, - }; - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - const blockedServers = [ - { name: 'blocked-server', extensionName: 'another-extension' }, - ]; - mockConfig.getBlockedMcpServers = vi.fn().mockReturnValue(blockedServers); - - const result = await mcpCommand.action!(mockContext, ''); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('server1 (from my-extension)'); - expect(message).toContain( - '🔴 \u001b[1mblocked-server (from another-extension)\u001b[0m - Blocked', - ); - } - }); - }); - - describe('schema functionality', () => { - it('should display tool schemas when schema argument is used', async () => { - const mockMcpServers = { - server1: { - command: 'cmd1', - description: 'This is a server description', - }, - }; - - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - - // Create tools with parameter schemas - const mockCallableTool1: CallableTool = { - callTool: vi.fn(), - tool: vi.fn(), - } as unknown as CallableTool; - const mockCallableTool2: CallableTool = { - callTool: vi.fn(), - tool: vi.fn(), - } as unknown as CallableTool; - - const tool1 = new DiscoveredMCPTool( - mockCallableTool1, - 'server1', - 'tool1', - 'This is tool 1 description', - { - type: Type.OBJECT, - properties: { - param1: { type: Type.STRING, description: 'First parameter' }, - }, - required: ['param1'], - }, - 'tool1', - ); - - const tool2 = new DiscoveredMCPTool( - mockCallableTool2, - 'server1', - 'tool2', - 'This is tool 2 description', - { - type: Type.OBJECT, - properties: { - param2: { type: Type.NUMBER, description: 'Second parameter' }, - }, - required: ['param2'], - }, - 'tool2', - ); - - const mockServerTools = [tool1, tool2]; - - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue(mockServerTools), - }); - - const result = await mcpCommand.action!(mockContext, 'schema'); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: expect.stringContaining('Configured MCP servers:'), - }); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - - // Check that server description is included - expect(message).toContain('Ready (2 tools)'); - expect(message).toContain('This is a server description'); - - // Check that tool descriptions and schemas are included - expect(message).toContain('This is tool 1 description'); - expect(message).toContain('Parameters:'); - expect(message).toContain('param1'); - expect(message).toContain('STRING'); - expect(message).toContain('This is tool 2 description'); - expect(message).toContain('param2'); - expect(message).toContain('NUMBER'); - } - }); - - it('should handle tools without parameter schemas gracefully', async () => { - const mockMcpServers = { - server1: { command: 'cmd1' }, - }; - - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - - // Mock tools without parameter schemas - const mockServerTools = [ - createMockMCPTool('tool1', 'server1', 'Tool without schema'), - ]; - - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue(mockServerTools), - }); - - const result = await mcpCommand.action!(mockContext, 'schema'); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: expect.stringContaining('Configured MCP servers:'), - }); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('tool1'); - expect(message).toContain('Tool without schema'); - // Should not crash when parameterSchema is undefined - } - }); - }); - - describe('argument parsing', () => { - beforeEach(() => { - const mockMcpServers = { - server1: { - command: 'cmd1', - description: 'Server description', - }, - }; - - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - - const mockServerTools = [ - createMockMCPTool('tool1', 'server1', 'Test tool'), - ]; - - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue(mockServerTools), - }); - }); - - it('should handle "descriptions" as alias for "desc"', async () => { - const result = await mcpCommand.action!(mockContext, 'descriptions'); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('Test tool'); - expect(message).toContain('Server description'); - } - }); - - it('should handle "nodescriptions" as alias for "nodesc"', async () => { - const result = await mcpCommand.action!(mockContext, 'nodescriptions'); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).not.toContain('Test tool'); - expect(message).not.toContain('Server description'); - expect(message).toContain('\u001b[36mtool1\u001b[0m'); - } - }); - - it('should handle mixed case arguments', async () => { - const result = await mcpCommand.action!(mockContext, 'DESC'); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('Test tool'); - expect(message).toContain('Server description'); - } - }); - - it('should handle multiple arguments - "schema desc"', async () => { - const result = await mcpCommand.action!(mockContext, 'schema desc'); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('Test tool'); - expect(message).toContain('Server description'); - expect(message).toContain('Parameters:'); - } - }); - - it('should handle multiple arguments - "desc schema"', async () => { - const result = await mcpCommand.action!(mockContext, 'desc schema'); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('Test tool'); - expect(message).toContain('Server description'); - expect(message).toContain('Parameters:'); - } - }); - - it('should handle "schema" alone showing descriptions', async () => { - const result = await mcpCommand.action!(mockContext, 'schema'); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('Test tool'); - expect(message).toContain('Server description'); - expect(message).toContain('Parameters:'); - } - }); - - it('should handle "nodesc" overriding "schema" - "schema nodesc"', async () => { - const result = await mcpCommand.action!(mockContext, 'schema nodesc'); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).not.toContain('Test tool'); - expect(message).not.toContain('Server description'); - expect(message).toContain('Parameters:'); // Schema should still show - expect(message).toContain('\u001b[36mtool1\u001b[0m'); - } - }); - - it('should handle "nodesc" overriding "desc" - "desc nodesc"', async () => { - const result = await mcpCommand.action!(mockContext, 'desc nodesc'); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).not.toContain('Test tool'); - expect(message).not.toContain('Server description'); - expect(message).not.toContain('Parameters:'); - expect(message).toContain('\u001b[36mtool1\u001b[0m'); - } - }); - - it('should handle "nodesc" overriding both "desc" and "schema" - "desc schema nodesc"', async () => { - const result = await mcpCommand.action!( - mockContext, - 'desc schema nodesc', - ); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).not.toContain('Test tool'); - expect(message).not.toContain('Server description'); - expect(message).toContain('Parameters:'); // Schema should still show - expect(message).toContain('\u001b[36mtool1\u001b[0m'); - } - }); - - it('should handle extra whitespace in arguments', async () => { - const result = await mcpCommand.action!(mockContext, ' desc schema '); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('Test tool'); - expect(message).toContain('Server description'); - expect(message).toContain('Parameters:'); - } - }); - - it('should handle empty arguments gracefully', async () => { - const result = await mcpCommand.action!(mockContext, ''); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).not.toContain('Test tool'); - expect(message).not.toContain('Server description'); - expect(message).not.toContain('Parameters:'); - expect(message).toContain('\u001b[36mtool1\u001b[0m'); - } - }); - - it('should handle unknown arguments gracefully', async () => { - const result = await mcpCommand.action!(mockContext, 'unknown arg'); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).not.toContain('Test tool'); - expect(message).not.toContain('Server description'); - expect(message).not.toContain('Parameters:'); - expect(message).toContain('\u001b[36mtool1\u001b[0m'); - } - }); - }); - - describe('edge cases', () => { - it('should handle empty server names gracefully', async () => { - const mockMcpServers = { - '': { command: 'cmd1' }, // Empty server name - }; - - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue([]), - }); - - const result = await mcpCommand.action!(mockContext, ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: expect.stringContaining('Configured MCP servers:'), - }); - }); - - it('should handle servers with special characters in names', async () => { - const mockMcpServers = { - 'server-with-dashes': { command: 'cmd1' }, - server_with_underscores: { command: 'cmd2' }, - 'server.with.dots': { command: 'cmd3' }, - }; - - mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue([]), - }); - - const result = await mcpCommand.action!(mockContext, ''); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - const message = result.content; - expect(message).toContain('server-with-dashes'); - expect(message).toContain('server_with_underscores'); - expect(message).toContain('server.with.dots'); - } - }); - }); - - 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().mockReturnValue(mockToolRegistry), - getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), - getPromptRegistry: vi.fn().mockResolvedValue({ - removePromptsByServer: vi.fn(), - }), - }, - }, - }); - // Mock the reloadCommands function - context.ui.reloadCommands = vi.fn(); - - const { MCPOAuthProvider } = await import('@google/gemini-cli-core'); - const mockAuthProvider = new MCPOAuthProvider(); - - const authCommand = mcpCommand.subCommands?.find( - (cmd) => cmd.name === 'auth', - ); - const result = await authCommand!.action!(context, 'test-server'); - - expect(mockAuthProvider.authenticate).toHaveBeenCalledWith( - 'test-server', - { enabled: true }, - 'http://localhost:3000', - expect.any(Object), - ); - expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith( - 'test-server', - ); - expect(mockGeminiClient.setTools).toHaveBeenCalled(); - expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1); - - 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'); - const mockAuthProvider = new MCPOAuthProvider(); - vi.mocked(mockAuthProvider.authenticate).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"); - } - }); - }); - - describe('refresh subcommand', () => { - it('should refresh the list of tools and display the status', async () => { - const mockToolRegistry = { - discoverMcpTools: vi.fn(), - restartMcpServers: vi.fn(), - getAllTools: vi.fn().mockReturnValue([]), - }; - const mockGeminiClient = { - setTools: vi.fn(), - }; - - const context = createMockCommandContext({ - services: { - config: { - getMcpServers: vi.fn().mockReturnValue({ server1: {} }), - getBlockedMcpServers: vi.fn().mockReturnValue([]), - getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), - getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), - getPromptRegistry: vi.fn().mockResolvedValue({ - getPromptsByServer: vi.fn().mockReturnValue([]), - }), - }, - }, - }); - // Mock the reloadCommands function, which is new logic. - context.ui.reloadCommands = vi.fn(); - - const refreshCommand = mcpCommand.subCommands?.find( - (cmd) => cmd.name === 'refresh', - ); - expect(refreshCommand).toBeDefined(); - - const result = await refreshCommand!.action!(context, ''); - - expect(context.ui.addItem).toHaveBeenCalledWith( - { - type: 'info', - text: 'Restarting MCP servers...', - }, + await mcpCommand.action!(mockContext, 'nodesc'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.MCP_STATUS, + showDescriptions: false, + showTips: false, + }), expect.any(Number), ); - expect(mockToolRegistry.restartMcpServers).toHaveBeenCalled(); - expect(mockGeminiClient.setTools).toHaveBeenCalled(); - expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1); - - expect(isMessageAction(result)).toBe(true); - if (isMessageAction(result)) { - expect(result.messageType).toBe('info'); - expect(result.content).toContain('Configured MCP servers:'); - } - }); - - it('should show an error if config is not available', async () => { - const contextWithoutConfig = createMockCommandContext({ - services: { - config: null, - }, - }); - - const refreshCommand = mcpCommand.subCommands?.find( - (cmd) => cmd.name === 'refresh', - ); - const result = await refreshCommand!.action!(contextWithoutConfig, ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Config not loaded.', - }); - }); - - it('should show an error if tool registry is not available', async () => { - mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined); - - const refreshCommand = mcpCommand.subCommands?.find( - (cmd) => cmd.name === 'refresh', - ); - const result = await refreshCommand!.action!(mockContext, ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Could not retrieve tool registry.', - }); }); }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index abf5134ee7..ba579d7c7c 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -18,302 +18,11 @@ import { getMCPServerStatus, MCPDiscoveryState, MCPServerStatus, - mcpServerRequiresOAuth, getErrorMessage, MCPOAuthTokenStorage, } from '@google/gemini-cli-core'; import { appEvents, AppEvent } from '../../utils/events.js'; - -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 ( - context: CommandContext, - showDescriptions: boolean, - showSchema: boolean, - showTips: boolean = false, -): Promise => { - const { config } = context.services; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: 'Config not loaded.', - }; - } - - const toolRegistry = config.getToolRegistry(); - if (!toolRegistry) { - return { - type: 'message', - messageType: 'error', - content: 'Could not retrieve tool registry.', - }; - } - - const mcpServers = config.getMcpServers() || {}; - const serverNames = Object.keys(mcpServers); - const blockedMcpServers = config.getBlockedMcpServers() || []; - - if (serverNames.length === 0 && blockedMcpServers.length === 0) { - const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp'; - return { - type: 'message', - messageType: 'info', - content: `No MCP servers configured. Please view MCP documentation in your browser: ${docsUrl} or use the cli /docs command`, - }; - } - - // Check if any servers are still connecting - const connectingServers = serverNames.filter( - (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, - ); - const discoveryState = getMCPDiscoveryState(); - - let message = ''; - - // Add overall discovery status message if needed - if ( - discoveryState === MCPDiscoveryState.IN_PROGRESS || - connectingServers.length > 0 - ) { - message += `${COLOR_YELLOW}⏳ MCP servers are starting up (${connectingServers.length} initializing)...${RESET_COLOR}\n`; - message += `${COLOR_CYAN}Note: First startup may take longer. Tool availability will update automatically.${RESET_COLOR}\n\n`; - } - - message += 'Configured MCP servers:\n\n'; - - const allTools = toolRegistry.getAllTools(); - for (const serverName of serverNames) { - const serverTools = allTools.filter( - (tool) => - tool instanceof DiscoveredMCPTool && tool.serverName === serverName, - ) as DiscoveredMCPTool[]; - const promptRegistry = await config.getPromptRegistry(); - const serverPrompts = promptRegistry.getPromptsByServer(serverName) || []; - - const originalStatus = getMCPServerStatus(serverName); - const hasCachedItems = serverTools.length > 0 || serverPrompts.length > 0; - - // If the server is "disconnected" but has prompts or cached tools, display it as Ready - // by using CONNECTED as the display status. - const status = - originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems - ? MCPServerStatus.CONNECTED - : originalStatus; - - // Add status indicator with descriptive text - let statusIndicator = ''; - let statusText = ''; - switch (status) { - case MCPServerStatus.CONNECTED: - statusIndicator = '🟢'; - statusText = 'Ready'; - break; - case MCPServerStatus.CONNECTING: - statusIndicator = '🔄'; - statusText = 'Starting... (first startup may take longer)'; - break; - case MCPServerStatus.DISCONNECTED: - default: - statusIndicator = '🔴'; - statusText = 'Disconnected'; - break; - } - - // Get server description if available - const server = mcpServers[serverName]; - let serverDisplayName = serverName; - if (server.extensionName) { - serverDisplayName += ` (from ${server.extensionName})`; - } - - // 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 tokenStorage = new MCPOAuthTokenStorage(); - const hasToken = await tokenStorage.getCredentials(serverName); - if (hasToken) { - const isExpired = tokenStorage.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) { - const parts = []; - if (serverTools.length > 0) { - parts.push( - `${serverTools.length} ${serverTools.length === 1 ? 'tool' : 'tools'}`, - ); - } - if (serverPrompts.length > 0) { - parts.push( - `${serverPrompts.length} ${ - serverPrompts.length === 1 ? 'prompt' : 'prompts' - }`, - ); - } - if (parts.length > 0) { - message += ` (${parts.join(', ')})`; - } else { - message += ` (0 tools)`; - } - } else if (status === MCPServerStatus.CONNECTING) { - message += ` (tools and prompts will appear when ready)`; - } else { - message += ` (${serverTools.length} tools cached)`; - } - - // Add server description with proper handling of multi-line descriptions - if (showDescriptions && server?.description) { - const descLines = server.description.trim().split('\n'); - if (descLines) { - message += ':\n'; - for (const descLine of descLines) { - message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`; - } - } else { - message += '\n'; - } - } else { - message += '\n'; - } - - // Reset formatting after server entry - message += RESET_COLOR; - - if (serverTools.length > 0) { - message += ` ${COLOR_CYAN}Tools:${RESET_COLOR}\n`; - serverTools.forEach((tool) => { - if (showDescriptions && tool.description) { - // Format tool name in cyan using simple ANSI cyan color - message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}`; - - // Handle multi-line descriptions by properly indenting and preserving formatting - const descLines = tool.description.trim().split('\n'); - if (descLines) { - message += ':\n'; - for (const descLine of descLines) { - message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`; - } - } else { - message += '\n'; - } - // Reset is handled inline with each line now - } else { - // Use cyan color for the tool name even when not showing descriptions - message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}\n`; - } - const parameters = - tool.schema.parametersJsonSchema ?? tool.schema.parameters; - if (showSchema && parameters) { - // Prefix the parameters in cyan - message += ` ${COLOR_CYAN}Parameters:${RESET_COLOR}\n`; - - const paramsLines = JSON.stringify(parameters, null, 2) - .trim() - .split('\n'); - if (paramsLines) { - for (const paramsLine of paramsLines) { - message += ` ${COLOR_GREEN}${paramsLine}${RESET_COLOR}\n`; - } - } - } - }); - } - if (serverPrompts.length > 0) { - if (serverTools.length > 0) { - message += '\n'; - } - message += ` ${COLOR_CYAN}Prompts:${RESET_COLOR}\n`; - serverPrompts.forEach((prompt: DiscoveredMCPPrompt) => { - if (showDescriptions && prompt.description) { - message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}`; - const descLines = prompt.description.trim().split('\n'); - if (descLines) { - message += ':\n'; - for (const descLine of descLines) { - message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`; - } - } else { - message += '\n'; - } - } else { - message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}\n`; - } - }); - } - - if (serverTools.length === 0 && serverPrompts.length === 0) { - message += ' No tools or prompts available\n'; - } else if (serverTools.length === 0) { - message += ' No tools available'; - if (originalStatus === MCPServerStatus.DISCONNECTED && needsAuthHint) { - message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`; - } - message += '\n'; - } else if ( - originalStatus === MCPServerStatus.DISCONNECTED && - needsAuthHint - ) { - // This case is for when serverTools.length > 0 - message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}\n`; - } - message += '\n'; - } - - for (const server of blockedMcpServers) { - let serverDisplayName = server.name; - if (server.extensionName) { - serverDisplayName += ` (from ${server.extensionName})`; - } - message += `🔴 \u001b[1m${serverDisplayName}\u001b[0m - Blocked\n\n`; - } - - // Add helpful tips when no arguments are provided - if (showTips) { - message += '\n'; - message += `${COLOR_CYAN}💡 Tips:${RESET_COLOR}\n`; - 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'; - } - - // Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal - message += RESET_COLOR; - - return { - type: 'message', - messageType: 'info', - content: message, - }; -}; +import { MessageType, type HistoryItemMcpStatus } from '../types.js'; const authCommand: SlashCommand = { name: 'auth', @@ -460,7 +169,28 @@ const listCommand: SlashCommand = { name: 'list', description: 'List configured MCP servers and tools', kind: CommandKind.BUILT_IN, - action: async (context: CommandContext, args: string) => { + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const toolRegistry = config.getToolRegistry(); + if (!toolRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Could not retrieve tool registry.', + }; + } + const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean); const hasDesc = @@ -470,14 +200,79 @@ const listCommand: SlashCommand = { lowerCaseArgs.includes('nodescriptions'); const showSchema = lowerCaseArgs.includes('schema'); - // Show descriptions if `desc` or `schema` is present, - // but `nodesc` takes precedence and disables them. const showDescriptions = !hasNodesc && (hasDesc || showSchema); - - // Show tips only when no arguments are provided const showTips = lowerCaseArgs.length === 0; - return getMcpStatus(context, showDescriptions, showSchema, showTips); + const mcpServers = config.getMcpServers() || {}; + const serverNames = Object.keys(mcpServers); + const blockedMcpServers = config.getBlockedMcpServers() || []; + + const connectingServers = serverNames.filter( + (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, + ); + const discoveryState = getMCPDiscoveryState(); + const discoveryInProgress = + discoveryState === MCPDiscoveryState.IN_PROGRESS || + connectingServers.length > 0; + + const allTools = toolRegistry.getAllTools(); + const mcpTools = allTools.filter( + (tool) => tool instanceof DiscoveredMCPTool, + ) as DiscoveredMCPTool[]; + + const promptRegistry = await config.getPromptRegistry(); + const mcpPrompts = promptRegistry + .getAllPrompts() + .filter( + (prompt) => + 'serverName' in prompt && + serverNames.includes(prompt.serverName as string), + ) as DiscoveredMCPPrompt[]; + + const authStatus: HistoryItemMcpStatus['authStatus'] = {}; + const tokenStorage = new MCPOAuthTokenStorage(); + for (const serverName of serverNames) { + const server = mcpServers[serverName]; + if (server.oauth?.enabled) { + const creds = await tokenStorage.getCredentials(serverName); + if (creds) { + if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) { + authStatus[serverName] = 'expired'; + } else { + authStatus[serverName] = 'authenticated'; + } + } else { + authStatus[serverName] = 'unauthenticated'; + } + } else { + authStatus[serverName] = 'not-configured'; + } + } + + const mcpStatusItem: HistoryItemMcpStatus = { + type: MessageType.MCP_STATUS, + servers: mcpServers, + tools: mcpTools.map((tool) => ({ + serverName: tool.serverName, + name: tool.name, + description: tool.description, + schema: tool.schema, + })), + prompts: mcpPrompts.map((prompt) => ({ + serverName: prompt.serverName as string, + name: prompt.name, + description: prompt.description, + })), + authStatus, + blockedServers: blockedMcpServers, + discoveryInProgress, + connectingServers, + showDescriptions, + showSchema, + showTips, + }; + + context.ui.addItem(mcpStatusItem, Date.now()); }, }; @@ -487,7 +282,7 @@ const refreshCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: async ( context: CommandContext, - ): Promise => { + ): Promise => { const { config } = context.services; if (!config) { return { @@ -525,7 +320,7 @@ const refreshCommand: SlashCommand = { // Reload the slash commands to reflect the changes. context.ui.reloadCommands(); - return getMcpStatus(context, false, false, false); + return listCommand.action!(context, ''); }, }; @@ -536,7 +331,10 @@ export const mcpCommand: SlashCommand = { kind: CommandKind.BUILT_IN, subCommands: [listCommand, authCommand, refreshCommand], // Default action when no subcommand is provided - action: async (context: CommandContext, args: string) => + action: async ( + context: CommandContext, + args: string, + ): Promise => // If no subcommand, run the list command listCommand.action!(context, args), }; diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts index 32f450235f..5f2ef4efbc 100644 --- a/packages/cli/src/ui/commands/toolsCommand.test.ts +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -62,9 +62,11 @@ describe('toolsCommand', () => { await toolsCommand.action(mockContext, ''); expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - text: expect.stringContaining('No tools available'), - }), + { + type: MessageType.TOOLS_LIST, + tools: [], + showDescriptions: false, + }, expect.any(Number), ); }); @@ -81,10 +83,12 @@ describe('toolsCommand', () => { if (!toolsCommand.action) throw new Error('Action not defined'); await toolsCommand.action(mockContext, ''); - const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text; - expect(message).not.toContain('Reads files from the local system.'); - expect(message).toContain('File Reader'); - expect(message).toContain('Code Editor'); + const [message] = (mockContext.ui.addItem as vi.Mock).mock.calls[0]; + expect(message.type).toBe(MessageType.TOOLS_LIST); + expect(message.showDescriptions).toBe(false); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].displayName).toBe('File Reader'); + expect(message.tools[1].displayName).toBe('Code Editor'); }); it('should list tools with descriptions when "desc" arg is passed', async () => { @@ -99,8 +103,13 @@ describe('toolsCommand', () => { if (!toolsCommand.action) throw new Error('Action not defined'); await toolsCommand.action(mockContext, 'desc'); - const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text; - expect(message).toContain('Reads files from the local system.'); - expect(message).toContain('Edits code files.'); + const [message] = (mockContext.ui.addItem as vi.Mock).mock.calls[0]; + expect(message.type).toBe(MessageType.TOOLS_LIST); + expect(message.showDescriptions).toBe(true); + expect(message.tools).toHaveLength(2); + expect(message.tools[0].description).toBe( + 'Reads files from the local system.', + ); + expect(message.tools[1].description).toBe('Edits code files.'); }); }); diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 1f4681ead3..f1ec77dcbd 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -9,7 +9,7 @@ import { type SlashCommand, CommandKind, } from './types.js'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemToolsList } from '../types.js'; export const toolsCommand: SlashCommand = { name: 'tools', @@ -40,32 +40,16 @@ export const toolsCommand: SlashCommand = { // Filter out MCP tools by checking for the absence of a serverName property const geminiTools = tools.filter((tool) => !('serverName' in tool)); - let message = 'Available Gemini CLI tools:\n\n'; + const toolsListItem: HistoryItemToolsList = { + type: MessageType.TOOLS_LIST, + tools: geminiTools.map((tool) => ({ + name: tool.name, + displayName: tool.displayName, + description: tool.description, + })), + showDescriptions: useShowDescriptions, + }; - if (geminiTools.length > 0) { - geminiTools.forEach((tool) => { - if (useShowDescriptions && tool.description) { - message += ` - \u001b[36m${tool.displayName} (${tool.name})\u001b[0m:\n`; - - const greenColor = '\u001b[32m'; - const resetColor = '\u001b[0m'; - - // Handle multi-line descriptions - const descLines = tool.description.trim().split('\n'); - for (const descLine of descLines) { - message += ` ${greenColor}${descLine}${resetColor}\n`; - } - } else { - message += ` - \u001b[36m${tool.displayName}\u001b[0m\n`; - } - }); - } else { - message += ' No tools available\n'; - } - message += '\n'; - - message += '\u001b[0m'; - - context.ui.addItem({ type: MessageType.INFO, text: message }, Date.now()); + context.ui.addItem(toolsListItem, Date.now()); }, }; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 4322a05ab4..c265a72e47 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -26,6 +26,9 @@ import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import { Help } from './Help.js'; import type { SlashCommand } from '../commands/types.js'; import { ExtensionsList } from './views/ExtensionsList.js'; +import { getMCPServerStatus } from '@google/gemini-cli-core'; +import { ToolsList } from './views/ToolsList.js'; +import { McpStatus } from './views/McpStatus.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -127,6 +130,16 @@ export const HistoryItemDisplay: React.FC = ({ )} {itemForDisplay.type === 'extensions_list' && } + {itemForDisplay.type === 'tools_list' && ( + + )} + {itemForDisplay.type === 'mcp_status' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/views/McpStatus.test.tsx b/packages/cli/src/ui/components/views/McpStatus.test.tsx new file mode 100644 index 0000000000..cecd248ad5 --- /dev/null +++ b/packages/cli/src/ui/components/views/McpStatus.test.tsx @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi } from 'vitest'; +import { McpStatus } from './McpStatus.js'; +import { MCPServerStatus } from '@google/gemini-cli-core'; +import { MessageType } from '../../types.js'; + +describe('McpStatus', () => { + const baseProps = { + type: MessageType.MCP_STATUS, + servers: { + 'server-1': { + url: 'http://localhost:8080', + name: 'server-1', + description: 'A test server', + }, + }, + tools: [ + { + serverName: 'server-1', + name: 'tool-1', + description: 'A test tool', + schema: { + parameters: { + type: 'object', + properties: { + param1: { type: 'string' }, + }, + }, + }, + }, + ], + prompts: [], + blockedServers: [], + serverStatus: () => MCPServerStatus.CONNECTED, + authStatus: {}, + discoveryInProgress: false, + connectingServers: [], + showDescriptions: true, + showSchema: false, + showTips: false, + }; + + it('renders correctly with a connected server', () => { + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with authenticated OAuth status', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with expired OAuth status', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with unauthenticated OAuth status', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with a disconnected server', async () => { + vi.spyOn( + await import('@google/gemini-cli-core'), + 'getMCPServerStatus', + ).mockReturnValue(MCPServerStatus.DISCONNECTED); + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly when discovery is in progress', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with schema enabled', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with parametersJsonSchema', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with tips enabled', () => { + const { lastFrame } = render(); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with prompts', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with a blocked server', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with a connecting server', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/views/McpStatus.tsx b/packages/cli/src/ui/components/views/McpStatus.tsx new file mode 100644 index 0000000000..46efd5b2fd --- /dev/null +++ b/packages/cli/src/ui/components/views/McpStatus.tsx @@ -0,0 +1,281 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { MCPServerConfig } from '@google/gemini-cli-core'; +import { MCPServerStatus } from '@google/gemini-cli-core'; +import { Box, Text } from 'ink'; +import type React from 'react'; +import { theme } from '../../semantic-colors.js'; +import type { + HistoryItemMcpStatus, + JsonMcpPrompt, + JsonMcpTool, +} from '../../types.js'; + +interface McpStatusProps { + servers: Record; + tools: JsonMcpTool[]; + prompts: JsonMcpPrompt[]; + blockedServers: Array<{ name: string; extensionName: string }>; + serverStatus: (serverName: string) => MCPServerStatus; + authStatus: HistoryItemMcpStatus['authStatus']; + discoveryInProgress: boolean; + connectingServers: string[]; + showDescriptions: boolean; + showSchema: boolean; + showTips: boolean; +} + +export const McpStatus: React.FC = ({ + servers, + tools, + prompts, + blockedServers, + serverStatus, + authStatus, + discoveryInProgress, + connectingServers, + showDescriptions, + showSchema, + showTips, +}) => { + const serverNames = Object.keys(servers); + + if (serverNames.length === 0 && blockedServers.length === 0) { + return ( + + No MCP servers configured. + + Please view MCP documentation in your browser:{' '} + + https://goo.gle/gemini-cli-docs-mcp + {' '} + or use the cli /docs command + + + ); + } + + return ( + + {discoveryInProgress && ( + + + ⏳ MCP servers are starting up ({connectingServers.length}{' '} + initializing)... + + + Note: First startup may take longer. Tool availability will update + automatically. + + + )} + + Configured MCP servers: + + + {serverNames.map((serverName) => { + const server = servers[serverName]; + const serverTools = tools.filter( + (tool) => tool.serverName === serverName, + ); + const serverPrompts = prompts.filter( + (prompt) => prompt.serverName === serverName, + ); + const originalStatus = serverStatus(serverName); + const hasCachedItems = + serverTools.length > 0 || serverPrompts.length > 0; + const status = + originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems + ? MCPServerStatus.CONNECTED + : originalStatus; + + let statusIndicator = ''; + let statusText = ''; + let statusColor = theme.text.primary; + + switch (status) { + case MCPServerStatus.CONNECTED: + statusIndicator = '🟢'; + statusText = 'Ready'; + statusColor = theme.status.success; + break; + case MCPServerStatus.CONNECTING: + statusIndicator = '🔄'; + statusText = 'Starting... (first startup may take longer)'; + statusColor = theme.status.warning; + break; + case MCPServerStatus.DISCONNECTED: + default: + statusIndicator = '🔴'; + statusText = 'Disconnected'; + statusColor = theme.status.error; + break; + } + + let serverDisplayName = serverName; + if (server.extensionName) { + serverDisplayName += ` (from ${server.extensionName})`; + } + + const toolCount = serverTools.length; + const promptCount = serverPrompts.length; + const parts = []; + if (toolCount > 0) { + parts.push(`${toolCount} ${toolCount === 1 ? 'tool' : 'tools'}`); + } + if (promptCount > 0) { + parts.push( + `${promptCount} ${promptCount === 1 ? 'prompt' : 'prompts'}`, + ); + } + + const serverAuthStatus = authStatus[serverName]; + let authStatusNode: React.ReactNode = null; + if (serverAuthStatus === 'authenticated') { + authStatusNode = (OAuth); + } else if (serverAuthStatus === 'expired') { + authStatusNode = ( + (OAuth expired) + ); + } else if (serverAuthStatus === 'unauthenticated') { + authStatusNode = ( + (OAuth not authenticated) + ); + } + + return ( + + + {statusIndicator} + {serverDisplayName} + + {' - '} + {statusText} + {status === MCPServerStatus.CONNECTED && + parts.length > 0 && + ` (${parts.join(', ')})`} + + {authStatusNode} + + {status === MCPServerStatus.CONNECTING && ( + (tools and prompts will appear when ready) + )} + {status === MCPServerStatus.DISCONNECTED && toolCount > 0 && ( + ({toolCount} tools cached) + )} + + {showDescriptions && server?.description && ( + + {server.description.trim()} + + )} + + {serverTools.length > 0 && ( + + Tools: + {serverTools.map((tool) => { + const schemaContent = + showSchema && + tool.schema && + (tool.schema.parametersJsonSchema || tool.schema.parameters) + ? JSON.stringify( + tool.schema.parametersJsonSchema ?? + tool.schema.parameters, + null, + 2, + ) + : null; + + return ( + + + - {tool.name} + + {showDescriptions && tool.description && ( + + + {tool.description.trim()} + + + )} + {schemaContent && ( + + Parameters: + + {schemaContent} + + + )} + + ); + })} + + )} + + {serverPrompts.length > 0 && ( + + Prompts: + {serverPrompts.map((prompt) => ( + + + - {prompt.name} + + {showDescriptions && prompt.description && ( + + + {prompt.description.trim()} + + + )} + + ))} + + )} + + ); + })} + + {blockedServers.map((server) => ( + + 🔴 + + {server.name} + {server.extensionName ? ` (from ${server.extensionName})` : ''} + + - Blocked + + ))} + + {showTips && ( + + 💡 Tips: + + {' '}- Use /mcp desc to show + server and tool descriptions + + + {' '}- Use /mcp schema to + show tool parameter schemas + + + {' '}- Use /mcp nodesc to + hide descriptions + + + {' '}- Use{' '} + /mcp auth <server-name>{' '} + to authenticate with OAuth-enabled servers + + + {' '}- Press Ctrl+T to + toggle tool descriptions on/off + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/views/ToolsList.test.tsx b/packages/cli/src/ui/components/views/ToolsList.test.tsx new file mode 100644 index 0000000000..553ba3d872 --- /dev/null +++ b/packages/cli/src/ui/components/views/ToolsList.test.tsx @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect } from 'vitest'; +import { ToolsList } from './ToolsList.js'; +import { type ToolDefinition } from '../../types.js'; + +const mockTools: ToolDefinition[] = [ + { + name: 'test-tool-one', + displayName: 'Test Tool One', + description: 'This is the first test tool.', + }, + { + name: 'test-tool-two', + displayName: 'Test Tool Two', + description: `This is the second test tool. + 1. Tool descriptions support markdown formatting. + 2. **note** use this tool wisely and be sure to consider how this tool interacts with word wrap. + 3. **important** this tool is awesome.`, + }, + { + name: 'test-tool-three', + displayName: 'Test Tool Three', + description: 'This is the third test tool.', + }, +]; + +describe('', () => { + it('renders correctly with descriptions', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly without descriptions', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders correctly with no tools', () => { + const { lastFrame } = render( + , + ); + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/views/ToolsList.tsx b/packages/cli/src/ui/components/views/ToolsList.tsx new file mode 100644 index 0000000000..9326f68942 --- /dev/null +++ b/packages/cli/src/ui/components/views/ToolsList.tsx @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { type ToolDefinition } from '../../types.js'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; + +interface ToolsListProps { + tools: readonly ToolDefinition[]; + showDescriptions: boolean; + terminalWidth: number; +} + +export const ToolsList: React.FC = ({ + tools, + showDescriptions, + terminalWidth, +}) => ( + + + Available Gemini CLI tools: + + + {tools.length > 0 ? ( + tools.map((tool) => ( + + {' '}- + + + {tool.displayName} + {showDescriptions ? ` (${tool.name})` : ''} + + {showDescriptions && tool.description && ( + + )} + + + )) + ) : ( + No tools available + )} + +); diff --git a/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap b/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap new file mode 100644 index 0000000000..293581a707 --- /dev/null +++ b/packages/cli/src/ui/components/views/__snapshots__/McpStatus.test.tsx.snap @@ -0,0 +1,166 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`McpStatus > renders correctly when discovery is in progress 1`] = ` +"⏳ MCP servers are starting up (0 initializing)... +Note: First startup may take longer. Tool availability will update automatically. + +Configured MCP servers: + +🟢 server-1 - Ready (1 tool) +A test server + Tools: + - tool-1 + A test tool +" +`; + +exports[`McpStatus > renders correctly with a blocked server 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) +A test server + Tools: + - tool-1 + A test tool + +🔴 server-1 (from test-extension) - Blocked +" +`; + +exports[`McpStatus > renders correctly with a connected server 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) +A test server + Tools: + - tool-1 + A test tool +" +`; + +exports[`McpStatus > renders correctly with a connecting server 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) +A test server + Tools: + - tool-1 + A test tool +" +`; + +exports[`McpStatus > renders correctly with a disconnected server 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) +A test server + Tools: + - tool-1 + A test tool +" +`; + +exports[`McpStatus > renders correctly with authenticated OAuth status 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) (OAuth) +A test server + Tools: + - tool-1 + A test tool +" +`; + +exports[`McpStatus > renders correctly with expired OAuth status 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) (OAuth expired) +A test server + Tools: + - tool-1 + A test tool +" +`; + +exports[`McpStatus > renders correctly with parametersJsonSchema 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) +A test server + Tools: + - tool-1 + A test tool + Parameters: + { + "type": "object", + "properties": { + "param1": { + "type": "string" + } + } + } +" +`; + +exports[`McpStatus > renders correctly with prompts 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool, 1 prompt) +A test server + Tools: + - tool-1 + A test tool + Prompts: + - prompt-1 + A test prompt +" +`; + +exports[`McpStatus > renders correctly with schema enabled 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) +A test server + Tools: + - tool-1 + A test tool + Parameters: + { + "type": "object", + "properties": { + "param1": { + "type": "string" + } + } + } +" +`; + +exports[`McpStatus > renders correctly with tips enabled 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) +A test server + Tools: + - tool-1 + A test tool + + +💡 Tips: + - Use /mcp desc to show server and tool descriptions + - Use /mcp schema to show tool parameter schemas + - Use /mcp nodesc to hide descriptions + - Use /mcp auth to authenticate with OAuth-enabled servers + - Press Ctrl+T to toggle tool descriptions on/off" +`; + +exports[`McpStatus > renders correctly with unauthenticated OAuth status 1`] = ` +"Configured MCP servers: + +🟢 server-1 - Ready (1 tool) (OAuth not authenticated) +A test server + Tools: + - tool-1 + A test tool +" +`; diff --git a/packages/cli/src/ui/components/views/__snapshots__/ToolsList.test.tsx.snap b/packages/cli/src/ui/components/views/__snapshots__/ToolsList.test.tsx.snap new file mode 100644 index 0000000000..44e359385a --- /dev/null +++ b/packages/cli/src/ui/components/views/__snapshots__/ToolsList.test.tsx.snap @@ -0,0 +1,32 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders correctly with descriptions 1`] = ` +"Available Gemini CLI tools: + + - Test Tool One (test-tool-one) + This is the first test tool. + - Test Tool Two (test-tool-two) + This is the second test tool. + 1. Tool descriptions support markdown formatting. + 2. note use this tool wisely and be sure to consider how this tool interacts with word wrap. + 3. important this tool is awesome. + - Test Tool Three (test-tool-three) + This is the third test tool. +" +`; + +exports[` > renders correctly with no tools 1`] = ` +"Available Gemini CLI tools: + + No tools available +" +`; + +exports[` > renders correctly without descriptions 1`] = ` +"Available Gemini CLI tools: + + - Test Tool One + - Test Tool Two + - Test Tool Three +" +`; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 36a9bbb0fd..e4f2abee60 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -6,6 +6,7 @@ import type { CompressionStatus, + MCPServerConfig, ThoughtSummary, ToolCallConfirmationDetails, ToolConfirmationOutcome, @@ -164,6 +165,53 @@ export type HistoryItemExtensionsList = HistoryItemBase & { type: 'extensions_list'; }; +export interface ToolDefinition { + name: string; + displayName: string; + description?: string; +} + +export type HistoryItemToolsList = HistoryItemBase & { + type: 'tools_list'; + tools: ToolDefinition[]; + showDescriptions: boolean; +}; + +// JSON-friendly types for using as a simple data model showing info about an +// MCP Server. +export interface JsonMcpTool { + serverName: string; + name: string; + description?: string; + schema?: { + parametersJsonSchema?: unknown; + parameters?: unknown; + }; +} + +export interface JsonMcpPrompt { + serverName: string; + name: string; + description?: string; +} + +export type HistoryItemMcpStatus = HistoryItemBase & { + type: 'mcp_status'; + servers: Record; + tools: JsonMcpTool[]; + prompts: JsonMcpPrompt[]; + authStatus: Record< + string, + 'authenticated' | 'expired' | 'unauthenticated' | 'not-configured' + >; + blockedServers: Array<{ name: string; extensionName: string }>; + discoveryInProgress: boolean; + connectingServers: string[]; + showDescriptions: boolean; + showSchema: boolean; + showTips: boolean; +}; + // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -184,7 +232,9 @@ export type HistoryItemWithoutId = | HistoryItemToolStats | HistoryItemQuit | HistoryItemCompression - | HistoryItemExtensionsList; + | HistoryItemExtensionsList + | HistoryItemToolsList + | HistoryItemMcpStatus; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -203,6 +253,8 @@ export enum MessageType { GEMINI = 'gemini', COMPRESSION = 'compression', EXTENSIONS_LIST = 'extensions_list', + TOOLS_LIST = 'tools_list', + MCP_STATUS = 'mcp_status', } // Simplified message structure for internal feedback