Fix /tool and /mcp commands to not write terminal escape codes directly (#10010)

This commit is contained in:
Jacob Richman
2025-09-29 14:27:06 -07:00
committed by GitHub
parent cea1a867b6
commit d37fff7fd6
12 changed files with 985 additions and 1225 deletions

View File

@@ -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<typeof vi.fn>;
getBlockedMcpServers: ReturnType<typeof vi.fn>;
getPromptRegistry: ReturnType<typeof vi.fn>;
getGeminiClient: ReturnType<typeof vi.fn>;
};
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 <server-name>');
}
});
it('should show message when no OAuth servers are configured', async () => {
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'regular-server': {},
}),
},
},
});
const authCommand = mcpCommand.subCommands?.find(
(cmd) => cmd.name === 'auth',
);
const result = await authCommand!.action!(context, '');
expect(isMessageAction(result)).toBe(true);
if (isMessageAction(result)) {
expect(result.messageType).toBe('info');
expect(result.content).toBe(
'No MCP servers configured with OAuth authentication.',
);
}
});
it('should authenticate with a specific server', async () => {
const mockToolRegistry = {
discoverToolsForServer: vi.fn(),
};
const mockGeminiClient = {
setTools: vi.fn(),
};
const context = createMockCommandContext({
services: {
config: {
getMcpServers: vi.fn().mockReturnValue({
'test-server': {
url: 'http://localhost:3000',
oauth: { enabled: true },
},
}),
getToolRegistry: vi.fn().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.',
});
});
});
});

View File

@@ -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<SlashCommandActionReturn> => {
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 <server-name>${RESET_COLOR} to authenticate with OAuth-enabled servers\n`;
message += ` • Press ${COLOR_CYAN}Ctrl+T${RESET_COLOR} to toggle tool descriptions on/off\n`;
message += '\n';
}
// 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<void | MessageActionReturn> => {
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<SlashCommandActionReturn> => {
): Promise<void | SlashCommandActionReturn> => {
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<void | SlashCommandActionReturn> =>
// If no subcommand, run the list command
listCommand.action!(context, args),
};

View File

@@ -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.');
});
});

View File

@@ -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());
},
};

View File

@@ -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<HistoryItemDisplayProps> = ({
<CompressionMessage compression={itemForDisplay.compression} />
)}
{itemForDisplay.type === 'extensions_list' && <ExtensionsList />}
{itemForDisplay.type === 'tools_list' && (
<ToolsList
terminalWidth={terminalWidth}
tools={itemForDisplay.tools}
showDescriptions={itemForDisplay.showDescriptions}
/>
)}
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
</Box>
);
};

View File

@@ -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(<McpStatus {...baseProps} />);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with authenticated OAuth status', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} authStatus={{ 'server-1': 'authenticated' }} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with expired OAuth status', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} authStatus={{ 'server-1': 'expired' }} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with unauthenticated OAuth status', () => {
const { lastFrame } = render(
<McpStatus
{...baseProps}
authStatus={{ 'server-1': 'unauthenticated' }}
/>,
);
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(<McpStatus {...baseProps} />);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly when discovery is in progress', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} discoveryInProgress={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with schema enabled', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} showSchema={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with parametersJsonSchema', () => {
const { lastFrame } = render(
<McpStatus
{...baseProps}
tools={[
{
serverName: 'server-1',
name: 'tool-1',
description: 'A test tool',
schema: {
parametersJsonSchema: {
type: 'object',
properties: {
param1: { type: 'string' },
},
},
},
},
]}
showSchema={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with tips enabled', () => {
const { lastFrame } = render(<McpStatus {...baseProps} showTips={true} />);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with prompts', () => {
const { lastFrame } = render(
<McpStatus
{...baseProps}
prompts={[
{
serverName: 'server-1',
name: 'prompt-1',
description: 'A test prompt',
},
]}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with a blocked server', () => {
const { lastFrame } = render(
<McpStatus
{...baseProps}
blockedServers={[{ name: 'server-1', extensionName: 'test-extension' }]}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with a connecting server', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} connectingServers={['server-1']} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -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<string, MCPServerConfig>;
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<McpStatusProps> = ({
servers,
tools,
prompts,
blockedServers,
serverStatus,
authStatus,
discoveryInProgress,
connectingServers,
showDescriptions,
showSchema,
showTips,
}) => {
const serverNames = Object.keys(servers);
if (serverNames.length === 0 && blockedServers.length === 0) {
return (
<Box flexDirection="column">
<Text>No MCP servers configured.</Text>
<Text>
Please view MCP documentation in your browser:{' '}
<Text color={theme.text.link}>
https://goo.gle/gemini-cli-docs-mcp
</Text>{' '}
or use the cli /docs command
</Text>
</Box>
);
}
return (
<Box flexDirection="column">
{discoveryInProgress && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.warning}>
MCP servers are starting up ({connectingServers.length}{' '}
initializing)...
</Text>
<Text color={theme.text.primary}>
Note: First startup may take longer. Tool availability will update
automatically.
</Text>
</Box>
)}
<Text bold>Configured MCP servers:</Text>
<Box height={1} />
{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 = <Text> (OAuth)</Text>;
} else if (serverAuthStatus === 'expired') {
authStatusNode = (
<Text color={theme.status.error}> (OAuth expired)</Text>
);
} else if (serverAuthStatus === 'unauthenticated') {
authStatusNode = (
<Text color={theme.status.warning}> (OAuth not authenticated)</Text>
);
}
return (
<Box key={serverName} flexDirection="column" marginBottom={1}>
<Box>
<Text color={statusColor}>{statusIndicator} </Text>
<Text bold>{serverDisplayName}</Text>
<Text>
{' - '}
{statusText}
{status === MCPServerStatus.CONNECTED &&
parts.length > 0 &&
` (${parts.join(', ')})`}
</Text>
{authStatusNode}
</Box>
{status === MCPServerStatus.CONNECTING && (
<Text> (tools and prompts will appear when ready)</Text>
)}
{status === MCPServerStatus.DISCONNECTED && toolCount > 0 && (
<Text> ({toolCount} tools cached)</Text>
)}
{showDescriptions && server?.description && (
<Text color={theme.text.secondary}>
{server.description.trim()}
</Text>
)}
{serverTools.length > 0 && (
<Box flexDirection="column" marginLeft={2}>
<Text color={theme.text.primary}>Tools:</Text>
{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 (
<Box key={tool.name} flexDirection="column">
<Text>
- <Text color={theme.text.primary}>{tool.name}</Text>
</Text>
{showDescriptions && tool.description && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{tool.description.trim()}
</Text>
</Box>
)}
{schemaContent && (
<Box flexDirection="column" marginLeft={4}>
<Text color={theme.text.secondary}>Parameters:</Text>
<Text color={theme.text.secondary}>
{schemaContent}
</Text>
</Box>
)}
</Box>
);
})}
</Box>
)}
{serverPrompts.length > 0 && (
<Box flexDirection="column" marginLeft={2}>
<Text color={theme.text.primary}>Prompts:</Text>
{serverPrompts.map((prompt) => (
<Box key={prompt.name} flexDirection="column">
<Text>
- <Text color={theme.text.primary}>{prompt.name}</Text>
</Text>
{showDescriptions && prompt.description && (
<Box marginLeft={2}>
<Text color={theme.text.primary}>
{prompt.description.trim()}
</Text>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
);
})}
{blockedServers.map((server) => (
<Box key={server.name} marginBottom={1}>
<Text color={theme.status.error}>🔴 </Text>
<Text bold>
{server.name}
{server.extensionName ? ` (from ${server.extensionName})` : ''}
</Text>
<Text> - Blocked</Text>
</Box>
))}
{showTips && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.accent}>💡 Tips:</Text>
<Text>
{' '}- Use <Text color={theme.text.accent}>/mcp desc</Text> to show
server and tool descriptions
</Text>
<Text>
{' '}- Use <Text color={theme.text.accent}>/mcp schema</Text> to
show tool parameter schemas
</Text>
<Text>
{' '}- Use <Text color={theme.text.accent}>/mcp nodesc</Text> to
hide descriptions
</Text>
<Text>
{' '}- Use{' '}
<Text color={theme.text.accent}>/mcp auth &lt;server-name&gt;</Text>{' '}
to authenticate with OAuth-enabled servers
</Text>
<Text>
{' '}- Press <Text color={theme.text.accent}>Ctrl+T</Text> to
toggle tool descriptions on/off
</Text>
</Box>
)}
</Box>
);
};

View File

@@ -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('<ToolsList />', () => {
it('renders correctly with descriptions', () => {
const { lastFrame } = render(
<ToolsList
tools={mockTools}
showDescriptions={true}
terminalWidth={40}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly without descriptions', () => {
const { lastFrame } = render(
<ToolsList
tools={mockTools}
showDescriptions={false}
terminalWidth={40}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with no tools', () => {
const { lastFrame } = render(
<ToolsList tools={[]} showDescriptions={true} terminalWidth={40} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -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<ToolsListProps> = ({
tools,
showDescriptions,
terminalWidth,
}) => (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
Available Gemini CLI tools:
</Text>
<Box height={1} />
{tools.length > 0 ? (
tools.map((tool) => (
<Box key={tool.name} flexDirection="row">
<Text color={theme.text.primary}>{' '}- </Text>
<Box flexDirection="column">
<Text bold color={theme.text.accent}>
{tool.displayName}
{showDescriptions ? ` (${tool.name})` : ''}
</Text>
{showDescriptions && tool.description && (
<MarkdownDisplay
terminalWidth={terminalWidth}
text={tool.description}
isPending={false}
/>
)}
</Box>
</Box>
))
) : (
<Text color={theme.text.primary}> No tools available</Text>
)}
</Box>
);

View File

@@ -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 <server-name> 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
"
`;

View File

@@ -0,0 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolsList /> > 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[`<ToolsList /> > renders correctly with no tools 1`] = `
"Available Gemini CLI tools:
No tools available
"
`;
exports[`<ToolsList /> > renders correctly without descriptions 1`] = `
"Available Gemini CLI tools:
- Test Tool One
- Test Tool Two
- Test Tool Three
"
`;

View File

@@ -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<string, MCPServerConfig>;
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<HistoryItem, 'id'> 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