diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 1ee78e8c..3bc618a2 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -18,6 +18,7 @@ import { privacyCommand } from '../ui/commands/privacyCommand.js'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; +import { mcpCommand } from '../ui/commands/mcpCommand.js'; // Mock the command modules to isolate the service from the command implementations. vi.mock('../ui/commands/memoryCommand.js', () => ({ @@ -50,9 +51,12 @@ vi.mock('../ui/commands/compressCommand.js', () => ({ vi.mock('../ui/commands/extensionsCommand.js', () => ({ extensionsCommand: { name: 'extensions', description: 'Mock Extensions' }, })); +vi.mock('../ui/commands/mcpCommand.js', () => ({ + mcpCommand: { name: 'mcp', description: 'Mock MCP' }, +})); describe('CommandService', () => { - const subCommandLen = 11; + const subCommandLen = 12; describe('when using default production loader', () => { let commandService: CommandService; @@ -91,6 +95,7 @@ describe('CommandService', () => { expect(commandNames).toContain('about'); expect(commandNames).toContain('compress'); expect(commandNames).toContain('extensions'); + expect(commandNames).toContain('mcp'); }); it('should overwrite any existing commands when called again', async () => { @@ -124,6 +129,7 @@ describe('CommandService', () => { compressCommand, extensionsCommand, helpCommand, + mcpCommand, memoryCommand, privacyCommand, statsCommand, diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index 6c81cd0c..49e26833 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -8,6 +8,7 @@ import { SlashCommand } from '../ui/commands/types.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; +import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { chatCommand } from '../ui/commands/chatCommand.js'; @@ -25,6 +26,7 @@ const loadBuiltInCommands = async (): Promise => [ compressCommand, extensionsCommand, helpCommand, + mcpCommand, memoryCommand, privacyCommand, statsCommand, diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts new file mode 100644 index 00000000..0a8d8306 --- /dev/null +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -0,0 +1,756 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { mcpCommand } from './mcpCommand.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { + MCPServerStatus, + MCPDiscoveryState, + getMCPServerStatus, + getMCPDiscoveryState, + DiscoveredMCPTool, +} from '@google/gemini-cli-core'; +import open from 'open'; +import { MessageActionReturn } from './types.js'; +import { Type, CallableTool } from '@google/genai'; + +// Mock external dependencies +vi.mock('open', () => ({ + default: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getMCPServerStatus: vi.fn(), + getMCPDiscoveryState: vi.fn(), + }; +}); + +// 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, + serverName: string, + description?: string, +) => + new DiscoveredMCPTool( + { + callTool: vi.fn(), + tool: vi.fn(), + } as unknown as CallableTool, + serverName, + name, + description || `Description for ${name}`, + { type: Type.OBJECT, properties: {} }, + name, // serverToolName same as name for simplicity + ); + +describe('mcpCommand', () => { + let mockContext: ReturnType; + let mockConfig: { + getToolRegistry: ReturnType; + getMcpServers: ReturnType; + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up default mock environment + delete process.env.SANDBOX; + + // Default mock implementations + vi.mocked(getMCPServerStatus).mockReturnValue(MCPServerStatus.CONNECTED); + vi.mocked(getMCPDiscoveryState).mockReturnValue( + MCPDiscoveryState.COMPLETED, + ); + + // Create mock config with all necessary methods + mockConfig = { + getToolRegistry: vi.fn().mockResolvedValue({ + getAllTools: vi.fn().mockReturnValue([]), + }), + getMcpServers: vi.fn().mockReturnValue({}), + }; + + mockContext = createMockCommandContext({ + services: { + config: mockConfig, + }, + }); + }); + + describe('basic functionality', () => { + it('should show an error if config is not available', async () => { + const contextWithoutConfig = createMockCommandContext({ + services: { + config: null, + }, + }); + + const result = await mcpCommand.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().mockResolvedValue(undefined); + + const result = await mcpCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not retrieve tool registry.', + }); + }); + }); + + describe('no MCP servers configured', () => { + beforeEach(() => { + mockConfig.getToolRegistry = vi.fn().mockResolvedValue({ + getAllTools: vi.fn().mockReturnValue([]), + }); + mockConfig.getMcpServers = vi.fn().mockReturnValue({}); + }); + + it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => { + process.env.SANDBOX = 'sandbox'; + + const result = await mcpCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'No MCP servers configured. Please open the following URL in your browser to view documentation:\nhttps://goo.gle/gemini-cli-docs-mcp', + }); + expect(open).not.toHaveBeenCalled(); + }); + + it('should display a message and open a URL when no MCP servers are configured outside a sandbox', async () => { + const result = await mcpCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'No MCP servers configured. Opening documentation in your browser: https://goo.gle/gemini-cli-docs-mcp', + }); + expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp'); + }); + }); + + describe('with configured MCP servers', () => { + beforeEach(() => { + const mockMcpServers = { + server1: { command: 'cmd1' }, + server2: { command: 'cmd2' }, + server3: { command: 'cmd3' }, + }; + + mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); + }); + + it('should display configured MCP servers with status indicators and their tools', async () => { + // Setup getMCPServerStatus mock implementation + vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { + if (serverName === 'server1') return MCPServerStatus.CONNECTED; + if (serverName === 'server2') return MCPServerStatus.CONNECTED; + return MCPServerStatus.DISCONNECTED; // server3 + }); + + // Mock tools from each server using actual DiscoveredMCPTool instances + const mockServer1Tools = [ + createMockMCPTool('server1_tool1', 'server1'), + createMockMCPTool('server1_tool2', 'server1'), + ]; + const mockServer2Tools = [createMockMCPTool('server2_tool1', 'server2')]; + const mockServer3Tools = [createMockMCPTool('server3_tool1', 'server3')]; + + const allTools = [ + ...mockServer1Tools, + ...mockServer2Tools, + ...mockServer3Tools, + ]; + + mockConfig.getToolRegistry = vi.fn().mockResolvedValue({ + getAllTools: vi.fn().mockReturnValue(allTools), + }); + + const result = 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 tools)', + ); + expect(message).toContain('server2_tool1'); + + // Server 3 - Disconnected + expect(message).toContain( + '🔴 \u001b[1mserver3\u001b[0m - Disconnected (1 tools cached)', + ); + 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'); + } + }); + + it('should display tool descriptions when desc argument is used', async () => { + const mockMcpServers = { + server1: { + command: 'cmd1', + description: 'This is a server description', + }, + }; + + 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().mockResolvedValue({ + 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:'); + } + }); + + 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().mockResolvedValue({ + 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().mockResolvedValue({ + 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 tools)', + ); + expect(message).toContain('\u001b[36mserver1_tool1\u001b[0m'); + expect(message).toContain( + '🔴 \u001b[1mserver2\u001b[0m - Disconnected (0 tools cached)', + ); + expect(message).toContain('No tools 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().mockResolvedValue({ + 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 tools)', + ); + expect(message).toContain( + '🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools will appear when ready)', + ); + } + }); + }); + + 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().mockResolvedValue({ + 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().mockResolvedValue({ + 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().mockResolvedValue({ + 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().mockResolvedValue({ + 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().mockResolvedValue({ + 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'); + } + }); + }); +}); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts new file mode 100644 index 00000000..fc266362 --- /dev/null +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SlashCommand, + SlashCommandActionReturn, + CommandContext, +} from './types.js'; +import { + DiscoveredMCPTool, + getMCPDiscoveryState, + getMCPServerStatus, + MCPDiscoveryState, + MCPServerStatus, +} from '@google/gemini-cli-core'; +import open from 'open'; + +const COLOR_GREEN = '\u001b[32m'; +const COLOR_YELLOW = '\u001b[33m'; +const COLOR_CYAN = '\u001b[36m'; +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 = await config.getToolRegistry(); + if (!toolRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Could not retrieve tool registry.', + }; + } + + const mcpServers = config.getMcpServers() || {}; + const serverNames = Object.keys(mcpServers); + + if (serverNames.length === 0) { + const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp'; + if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { + return { + type: 'message', + messageType: 'info', + content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`, + }; + } else { + // Open the URL in the browser + await open(docsUrl); + return { + type: 'message', + messageType: 'info', + content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`, + }; + } + } + + // 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 status = getMCPServerStatus(serverName); + + // 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]; + + // Format server header with bold formatting and status + message += `${statusIndicator} \u001b[1m${serverName}\u001b[0m - ${statusText}`; + + // Add tool count with conditional messaging + if (status === MCPServerStatus.CONNECTED) { + message += ` (${serverTools.length} tools)`; + } else if (status === MCPServerStatus.CONNECTING) { + message += ` (tools 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) { + 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`; + } + if (showSchema && tool.parameterSchema) { + // Prefix the parameters in cyan + message += ` ${COLOR_CYAN}Parameters:${RESET_COLOR}\n`; + + const paramsLines = JSON.stringify(tool.parameterSchema, null, 2) + .trim() + .split('\n'); + if (paramsLines) { + for (const paramsLine of paramsLines) { + message += ` ${COLOR_GREEN}${paramsLine}${RESET_COLOR}\n`; + } + } + } + }); + } else { + message += ' No tools available\n'; + } + message += '\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 += ` • 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, + }; +}; + +export const mcpCommand: SlashCommand = { + name: 'mcp', + description: 'list configured MCP servers and tools', + action: async (context: CommandContext, args: string) => { + const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean); + + const hasDesc = + lowerCaseArgs.includes('desc') || lowerCaseArgs.includes('descriptions'); + const hasNodesc = + lowerCaseArgs.includes('nodesc') || + 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); + }, +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index 20c8d7fe..3a0428d9 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -67,14 +67,7 @@ import { import open from 'open'; import { useSlashCommandProcessor } from './slashCommandProcessor.js'; import { MessageType, SlashCommandProcessorResult } from '../types.js'; -import { - Config, - MCPDiscoveryState, - MCPServerStatus, - getMCPDiscoveryState, - getMCPServerStatus, - GeminiClient, -} from '@google/gemini-cli-core'; +import { Config, GeminiClient } from '@google/gemini-cli-core'; import { useSessionStats } from '../contexts/SessionContext.js'; import { LoadedSettings } from '../../config/settings.js'; import * as ShowMemoryCommandModule from './useShowMemoryCommand.js'; @@ -102,8 +95,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { await importOriginal(); return { ...actual, - getMCPServerStatus: vi.fn(), - getMCPDiscoveryState: vi.fn(), }; }); @@ -760,448 +751,4 @@ describe('useSlashCommandProcessor', () => { expect(commandResult).toEqual({ type: 'handled' }); }); }); - - describe('/mcp command', () => { - it('should show an error if tool registry is not available', async () => { - mockConfig = { - ...mockConfig, - getToolRegistry: vi.fn().mockResolvedValue(undefined), - } as unknown as Config; - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandProcessorResult | false = false; - await act(async () => { - commandResult = await handleSlashCommand('/mcp'); - }); - - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: MessageType.ERROR, - text: 'Could not retrieve tool registry.', - }), - expect.any(Number), - ); - expect(commandResult).toEqual({ type: 'handled' }); - }); - - it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => { - process.env.SANDBOX = 'sandbox'; - mockConfig = { - ...mockConfig, - getToolRegistry: vi.fn().mockResolvedValue({ - getToolsByServer: vi.fn().mockReturnValue([]), - }), - getMcpServers: vi.fn().mockReturnValue({}), - } as unknown as Config; - - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandProcessorResult | false = false; - await act(async () => { - commandResult = await handleSlashCommand('/mcp'); - }); - - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: MessageType.INFO, - text: `No MCP servers configured. Please open the following URL in your browser to view documentation:\nhttps://goo.gle/gemini-cli-docs-mcp`, - }), - expect.any(Number), - ); - expect(commandResult).toEqual({ type: 'handled' }); - delete process.env.SANDBOX; - }); - - it('should display a message and open a URL when no MCP servers are configured outside a sandbox', async () => { - mockConfig = { - ...mockConfig, - getToolRegistry: vi.fn().mockResolvedValue({ - getToolsByServer: vi.fn().mockReturnValue([]), - }), - getMcpServers: vi.fn().mockReturnValue({}), - } as unknown as Config; - - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandProcessorResult | false = false; - await act(async () => { - commandResult = await handleSlashCommand('/mcp'); - }); - - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: MessageType.INFO, - text: 'No MCP servers configured. Opening documentation in your browser: https://goo.gle/gemini-cli-docs-mcp', - }), - expect.any(Number), - ); - expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp'); - expect(commandResult).toEqual({ type: 'handled' }); - }); - - it('should display configured MCP servers with status indicators and their tools', async () => { - // Mock MCP servers configuration - const mockMcpServers = { - server1: { command: 'cmd1' }, - server2: { command: 'cmd2' }, - server3: { command: 'cmd3' }, - }; - - // Setup getMCPServerStatus mock implementation - use all CONNECTED to avoid startup message in this test - vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { - if (serverName === 'server1') return MCPServerStatus.CONNECTED; - if (serverName === 'server2') return MCPServerStatus.CONNECTED; - return MCPServerStatus.DISCONNECTED; // Default for server3 and others - }); - - // Setup getMCPDiscoveryState mock to return completed so no startup message is shown - vi.mocked(getMCPDiscoveryState).mockReturnValue( - MCPDiscoveryState.COMPLETED, - ); - - // Mock tools from each server - const mockServer1Tools = [ - { name: 'server1_tool1' }, - { name: 'server1_tool2' }, - ]; - - const mockServer2Tools = [{ name: 'server2_tool1' }]; - - const mockServer3Tools = [{ name: 'server3_tool1' }]; - - const mockGetToolsByServer = vi.fn().mockImplementation((serverName) => { - if (serverName === 'server1') return mockServer1Tools; - if (serverName === 'server2') return mockServer2Tools; - if (serverName === 'server3') return mockServer3Tools; - return []; - }); - - mockConfig = { - ...mockConfig, - getToolRegistry: vi.fn().mockResolvedValue({ - getToolsByServer: mockGetToolsByServer, - }), - getMcpServers: vi.fn().mockReturnValue(mockMcpServers), - } as unknown as Config; - - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandProcessorResult | false = false; - await act(async () => { - commandResult = await handleSlashCommand('/mcp'); - }); - - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: MessageType.INFO, - text: expect.stringContaining('Configured MCP servers:'), - }), - expect.any(Number), - ); - - // Check that the message contains details about servers and their tools - const message = mockAddItem.mock.calls[1][0].text; - // Server 1 - Connected - expect(message).toContain( - '🟢 \u001b[1mserver1\u001b[0m - Ready (2 tools)', - ); - expect(message).toContain('\u001b[36mserver1_tool1\u001b[0m'); - expect(message).toContain('\u001b[36mserver1_tool2\u001b[0m'); - - // Server 2 - Connected - expect(message).toContain( - '🟢 \u001b[1mserver2\u001b[0m - Ready (1 tools)', - ); - expect(message).toContain('\u001b[36mserver2_tool1\u001b[0m'); - - // Server 3 - Disconnected - expect(message).toContain( - '🔴 \u001b[1mserver3\u001b[0m - Disconnected (1 tools cached)', - ); - expect(message).toContain('\u001b[36mserver3_tool1\u001b[0m'); - - expect(commandResult).toEqual({ type: 'handled' }); - }); - - it('should display tool descriptions when showToolDescriptions is true', async () => { - // Mock MCP servers configuration with server description - const mockMcpServers = { - server1: { - command: 'cmd1', - description: 'This is a server description', - }, - }; - - // Setup getMCPServerStatus mock implementation - vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { - if (serverName === 'server1') return MCPServerStatus.CONNECTED; - return MCPServerStatus.DISCONNECTED; - }); - - // Setup getMCPDiscoveryState mock to return completed - vi.mocked(getMCPDiscoveryState).mockReturnValue( - MCPDiscoveryState.COMPLETED, - ); - - // Mock tools from server with descriptions - const mockServerTools = [ - { name: 'tool1', description: 'This is tool 1 description' }, - { name: 'tool2', description: 'This is tool 2 description' }, - ]; - - mockConfig = { - ...mockConfig, - getToolRegistry: vi.fn().mockResolvedValue({ - getToolsByServer: vi.fn().mockReturnValue(mockServerTools), - }), - getMcpServers: vi.fn().mockReturnValue(mockMcpServers), - } as unknown as Config; - - const { handleSlashCommand } = getProcessor(true); - let commandResult: SlashCommandProcessorResult | false = false; - await act(async () => { - commandResult = await handleSlashCommand('/mcp'); - }); - - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: MessageType.INFO, - text: expect.stringContaining('Configured MCP servers:'), - }), - expect.any(Number), - ); - - const message = mockAddItem.mock.calls[1][0].text; - - // Check that server description is included (with ANSI color codes) - 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 (with ANSI color codes) - 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', - ); - - expect(commandResult).toEqual({ type: 'handled' }); - }); - - it('should indicate when a server has no tools', async () => { - // Mock MCP servers configuration - const mockMcpServers = { - server1: { command: 'cmd1' }, - server2: { command: 'cmd2' }, - }; - - // Setup getMCPServerStatus mock implementation - vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { - if (serverName === 'server1') return MCPServerStatus.CONNECTED; - if (serverName === 'server2') return MCPServerStatus.DISCONNECTED; - return MCPServerStatus.DISCONNECTED; - }); - - // Setup getMCPDiscoveryState mock to return completed - vi.mocked(getMCPDiscoveryState).mockReturnValue( - MCPDiscoveryState.COMPLETED, - ); - - // Mock tools from each server - server2 has no tools - const mockServer1Tools = [{ name: 'server1_tool1' }]; - - const mockServer2Tools: Array<{ name: string }> = []; - - const mockGetToolsByServer = vi.fn().mockImplementation((serverName) => { - if (serverName === 'server1') return mockServer1Tools; - if (serverName === 'server2') return mockServer2Tools; - return []; - }); - - mockConfig = { - ...mockConfig, - getToolRegistry: vi.fn().mockResolvedValue({ - getToolsByServer: mockGetToolsByServer, - }), - getMcpServers: vi.fn().mockReturnValue(mockMcpServers), - } as unknown as Config; - - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandProcessorResult | false = false; - await act(async () => { - commandResult = await handleSlashCommand('/mcp'); - }); - - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: MessageType.INFO, - text: expect.stringContaining('Configured MCP servers:'), - }), - expect.any(Number), - ); - - // Check that the message contains details about both servers and their tools - const message = mockAddItem.mock.calls[1][0].text; - expect(message).toContain( - '🟢 \u001b[1mserver1\u001b[0m - Ready (1 tools)', - ); - expect(message).toContain('\u001b[36mserver1_tool1\u001b[0m'); - expect(message).toContain( - '🔴 \u001b[1mserver2\u001b[0m - Disconnected (0 tools cached)', - ); - expect(message).toContain('No tools available'); - - expect(commandResult).toEqual({ type: 'handled' }); - }); - - it('should show startup indicator when servers are connecting', async () => { - // Mock MCP servers configuration - const mockMcpServers = { - server1: { command: 'cmd1' }, - server2: { command: 'cmd2' }, - }; - - // Setup getMCPServerStatus mock implementation with one server connecting - vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { - if (serverName === 'server1') return MCPServerStatus.CONNECTED; - if (serverName === 'server2') return MCPServerStatus.CONNECTING; - return MCPServerStatus.DISCONNECTED; - }); - - // Setup getMCPDiscoveryState mock to return in progress - vi.mocked(getMCPDiscoveryState).mockReturnValue( - MCPDiscoveryState.IN_PROGRESS, - ); - - // Mock tools from each server - const mockServer1Tools = [{ name: 'server1_tool1' }]; - const mockServer2Tools = [{ name: 'server2_tool1' }]; - - const mockGetToolsByServer = vi.fn().mockImplementation((serverName) => { - if (serverName === 'server1') return mockServer1Tools; - if (serverName === 'server2') return mockServer2Tools; - return []; - }); - - mockConfig = { - ...mockConfig, - getToolRegistry: vi.fn().mockResolvedValue({ - getToolsByServer: mockGetToolsByServer, - }), - getMcpServers: vi.fn().mockReturnValue(mockMcpServers), - } as unknown as Config; - - const { handleSlashCommand } = getProcessor(); - let commandResult: SlashCommandProcessorResult | false = false; - await act(async () => { - commandResult = await handleSlashCommand('/mcp'); - }); - - const message = mockAddItem.mock.calls[1][0].text; - - // 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 tools)', - ); - expect(message).toContain( - '🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools will appear when ready)', - ); - - expect(commandResult).toEqual({ type: 'handled' }); - }); - }); - - describe('/mcp schema', () => { - it('should display tool schemas and descriptions', async () => { - // Mock MCP servers configuration with server description - const mockMcpServers = { - server1: { - command: 'cmd1', - description: 'This is a server description', - }, - }; - - // Setup getMCPServerStatus mock implementation - vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { - if (serverName === 'server1') return MCPServerStatus.CONNECTED; - return MCPServerStatus.DISCONNECTED; - }); - - // Setup getMCPDiscoveryState mock to return completed - vi.mocked(getMCPDiscoveryState).mockReturnValue( - MCPDiscoveryState.COMPLETED, - ); - - // Mock tools from server with descriptions - const mockServerTools = [ - { - name: 'tool1', - description: 'This is tool 1 description', - schema: { - parameters: [{ name: 'param1', type: 'string' }], - }, - }, - { - name: 'tool2', - description: 'This is tool 2 description', - schema: { - parameters: [{ name: 'param2', type: 'number' }], - }, - }, - ]; - - mockConfig = { - ...mockConfig, - getToolRegistry: vi.fn().mockResolvedValue({ - getToolsByServer: vi.fn().mockReturnValue(mockServerTools), - }), - getMcpServers: vi.fn().mockReturnValue(mockMcpServers), - } as unknown as Config; - - const { handleSlashCommand } = getProcessor(true); - let commandResult: SlashCommandProcessorResult | false = false; - await act(async () => { - commandResult = await handleSlashCommand('/mcp schema'); - }); - - expect(mockAddItem).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - type: MessageType.INFO, - text: expect.stringContaining('Configured MCP servers:'), - }), - expect.any(Number), - ); - - const message = mockAddItem.mock.calls[1][0].text; - - // Check that server description is included - expect(message).toContain('Ready (2 tools)'); - expect(message).toContain('This is a server description'); - - // Check that tool schemas are included - expect(message).toContain('tool 1 description'); - expect(message).toContain('param1'); - expect(message).toContain('string'); - expect(message).toContain('tool 2 description'); - expect(message).toContain('param2'); - expect(message).toContain('number'); - - expect(commandResult).toEqual({ type: 'handled' }); - }); - }); }); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 67dbfcdd..8355ea19 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -10,15 +10,7 @@ import open from 'open'; import process from 'node:process'; import { UseHistoryManagerReturn } from './useHistoryManager.js'; import { useStateAndRef } from './useStateAndRef.js'; -import { - Config, - GitService, - Logger, - MCPDiscoveryState, - MCPServerStatus, - getMCPDiscoveryState, - getMCPServerStatus, -} from '@google/gemini-cli-core'; +import { Config, GitService, Logger } from '@google/gemini-cli-core'; import { useSessionStats } from '../contexts/SessionContext.js'; import { Message, @@ -234,205 +226,6 @@ export const useSlashCommandProcessor = ( description: 'set external editor preference', action: (_mainCommand, _subCommand, _args) => openEditorDialog(), }, - { - name: 'mcp', - description: 'list configured MCP servers and tools', - action: async (_mainCommand, _subCommand, _args) => { - // Check if the _subCommand includes a specific flag to control description visibility - let useShowDescriptions = showToolDescriptions; - if (_subCommand === 'desc' || _subCommand === 'descriptions') { - useShowDescriptions = true; - } else if ( - _subCommand === 'nodesc' || - _subCommand === 'nodescriptions' - ) { - useShowDescriptions = false; - } else if (_args === 'desc' || _args === 'descriptions') { - useShowDescriptions = true; - } else if (_args === 'nodesc' || _args === 'nodescriptions') { - useShowDescriptions = false; - } - // Check if the _subCommand includes a specific flag to show detailed tool schema - let useShowSchema = false; - if (_subCommand === 'schema' || _args === 'schema') { - useShowSchema = true; - } - - const toolRegistry = await config?.getToolRegistry(); - if (!toolRegistry) { - addMessage({ - type: MessageType.ERROR, - content: 'Could not retrieve tool registry.', - timestamp: new Date(), - }); - return; - } - - const mcpServers = config?.getMcpServers() || {}; - const serverNames = Object.keys(mcpServers); - - if (serverNames.length === 0) { - const docsUrl = 'https://goo.gle/gemini-cli-docs-mcp'; - if (process.env.SANDBOX && process.env.SANDBOX !== 'sandbox-exec') { - addMessage({ - type: MessageType.INFO, - content: `No MCP servers configured. Please open the following URL in your browser to view documentation:\n${docsUrl}`, - timestamp: new Date(), - }); - } else { - addMessage({ - type: MessageType.INFO, - content: `No MCP servers configured. Opening documentation in your browser: ${docsUrl}`, - timestamp: new Date(), - }); - await open(docsUrl); - } - return; - } - - // 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 += `\u001b[33m⏳ MCP servers are starting up (${connectingServers.length} initializing)...\u001b[0m\n`; - message += `\u001b[90mNote: First startup may take longer. Tool availability will update automatically.\u001b[0m\n\n`; - } - - message += 'Configured MCP servers:\n\n'; - - for (const serverName of serverNames) { - const serverTools = toolRegistry.getToolsByServer(serverName); - const status = getMCPServerStatus(serverName); - - // 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]; - - // Format server header with bold formatting and status - message += `${statusIndicator} \u001b[1m${serverName}\u001b[0m - ${statusText}`; - - // Add tool count with conditional messaging - if (status === MCPServerStatus.CONNECTED) { - message += ` (${serverTools.length} tools)`; - } else if (status === MCPServerStatus.CONNECTING) { - message += ` (tools will appear when ready)`; - } else { - message += ` (${serverTools.length} tools cached)`; - } - - // Add server description with proper handling of multi-line descriptions - if ((useShowDescriptions || useShowSchema) && server?.description) { - const greenColor = '\u001b[32m'; - const resetColor = '\u001b[0m'; - - const descLines = server.description.trim().split('\n'); - if (descLines) { - message += ':\n'; - for (const descLine of descLines) { - message += ` ${greenColor}${descLine}${resetColor}\n`; - } - } else { - message += '\n'; - } - } else { - message += '\n'; - } - - // Reset formatting after server entry - message += '\u001b[0m'; - - if (serverTools.length > 0) { - serverTools.forEach((tool) => { - if ( - (useShowDescriptions || useShowSchema) && - tool.description - ) { - // Format tool name in cyan using simple ANSI cyan color - message += ` - \u001b[36m${tool.name}\u001b[0m`; - - // Apply green color to the description text - const greenColor = '\u001b[32m'; - const resetColor = '\u001b[0m'; - - // 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 += ` ${greenColor}${descLine}${resetColor}\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 += ` - \u001b[36m${tool.name}\u001b[0m\n`; - } - if (useShowSchema) { - // Prefix the parameters in cyan - message += ` \u001b[36mParameters:\u001b[0m\n`; - // Apply green color to the parameter text - const greenColor = '\u001b[32m'; - const resetColor = '\u001b[0m'; - - const paramsLines = JSON.stringify( - tool.schema.parameters, - null, - 2, - ) - .trim() - .split('\n'); - if (paramsLines) { - for (const paramsLine of paramsLines) { - message += ` ${greenColor}${paramsLine}${resetColor}\n`; - } - } - } - }); - } else { - message += ' No tools available\n'; - } - message += '\n'; - } - - // Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal - message += '\u001b[0m'; - - addMessage({ - type: MessageType.INFO, - content: message, - timestamp: new Date(), - }); - }, - }, { name: 'tools', description: 'list available Gemini CLI tools',