/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { vi, describe, it, expect, beforeEach, afterEach, type Mock, } from 'vitest'; import { listMcpServers } from './list.js'; import { loadSettings } from '../../config/settings.js'; import { createTransport, debugLogger, type AdminControlsSettings, } from '@google/gemini-cli-core'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { ExtensionStorage } from '../../config/extensions/storage.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { McpServerEnablementManager } from '../../config/mcp/index.js'; import { createMockSettings } from '../../test-utils/settings.js'; vi.mock('../../config/settings.js', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadSettings: vi.fn(), }; }); vi.mock('../../config/extensions/storage.js', () => ({ ExtensionStorage: { getUserExtensionsDir: vi.fn(), }, })); vi.mock('../../config/extension-manager.js'); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const original = await importOriginal(); return { ...original, createTransport: vi.fn(), MCPServerStatus: { CONNECTED: 'CONNECTED', CONNECTING: 'CONNECTING', DISCONNECTED: 'DISCONNECTED', BLOCKED: 'BLOCKED', DISABLED: 'DISABLED', }, Storage: Object.assign( vi.fn().mockImplementation((_cwd: string) => ({ getGlobalSettingsPath: () => '/tmp/gemini/settings.json', getWorkspaceSettingsPath: () => '/tmp/gemini/workspace-settings.json', getProjectTempDir: () => '/test/home/.gemini/tmp/mocked_hash', })), { getGlobalSettingsPath: () => '/tmp/gemini/settings.json', getGlobalGeminiDir: () => '/tmp/gemini', }, ), GEMINI_DIR: '.gemini', getErrorMessage: (e: unknown) => e instanceof Error ? e.message : String(e), }; }); vi.mock('@modelcontextprotocol/sdk/client/index.js'); vi.mock('../utils.js', () => ({ exitCli: vi.fn(), })); const mockedGetUserExtensionsDir = ExtensionStorage.getUserExtensionsDir as Mock; const mockedLoadSettings = loadSettings as Mock; const mockedCreateTransport = createTransport as Mock; const MockedClient = Client as Mock; const MockedExtensionManager = ExtensionManager as Mock; interface MockClient { connect: Mock; ping: Mock; close: Mock; } interface MockExtensionManager { loadExtensions: Mock; } interface MockTransport { close: Mock; } describe('mcp list command', () => { let mockClient: MockClient; let mockExtensionManager: MockExtensionManager; let mockTransport: MockTransport; beforeEach(() => { vi.resetAllMocks(); vi.spyOn(debugLogger, 'log').mockImplementation(() => {}); McpServerEnablementManager.resetInstance(); // Use a mock for isFileEnabled to avoid reading real files vi.spyOn( McpServerEnablementManager.prototype, 'isFileEnabled', ).mockResolvedValue(true); mockTransport = { close: vi.fn() }; mockClient = { connect: vi.fn(), ping: vi.fn(), close: vi.fn(), }; mockExtensionManager = { loadExtensions: vi.fn(), }; MockedClient.mockImplementation(() => mockClient); MockedExtensionManager.mockImplementation(() => mockExtensionManager); mockedCreateTransport.mockResolvedValue(mockTransport); mockExtensionManager.loadExtensions.mockReturnValue([]); mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir'); }); afterEach(() => { vi.restoreAllMocks(); }); it('should display message when no servers configured', async () => { mockedLoadSettings.mockReturnValue(createMockSettings({ mcpServers: {} })); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith('No MCP servers configured.'); }); it('should display different server types with connected status', async () => { mockedLoadSettings.mockReturnValue( createMockSettings({ mcpServers: { 'stdio-server': { command: '/path/to/server', args: ['arg1'] }, 'sse-server': { url: 'https://example.com/sse', type: 'sse' }, 'http-server': { httpUrl: 'https://example.com/http' }, 'http-server-by-default': { url: 'https://example.com/http' }, 'http-server-with-type': { url: 'https://example.com/http', type: 'http', }, }, isTrusted: true, }), ); mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith('Configured MCP servers:\n'); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'stdio-server: /path/to/server arg1 (stdio) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'sse-server: https://example.com/sse (sse) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'http-server: https://example.com/http (http) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'http-server-by-default: https://example.com/http (http) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'http-server-with-type: https://example.com/http (http) - Connected', ), ); }); it('should display disconnected status when connection fails', async () => { mockedLoadSettings.mockReturnValue( createMockSettings({ mcpServers: { 'test-server': { command: '/test/server' }, }, isTrusted: true, }), ); mockClient.connect.mockRejectedValue(new Error('Connection failed')); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'test-server: /test/server (stdio) - Disconnected', ), ); }); it('should display connected status even if ping fails', async () => { mockedLoadSettings.mockReturnValue( createMockSettings({ mcpServers: { 'test-server': { command: '/test/server' }, }, isTrusted: true, }), ); mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockRejectedValue(new Error('Ping failed')); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('test-server: /test/server (stdio) - Connected'), ); }); it('should use configured timeout for connection', async () => { mockedLoadSettings.mockReturnValue( createMockSettings({ mcpServers: { 'test-server': { command: '/test/server', timeout: 12345 }, }, isTrusted: true, }), ); mockClient.connect.mockResolvedValue(undefined); await listMcpServers(); expect(mockClient.connect).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ timeout: 12345 }), ); expect(mockClient.ping).toHaveBeenCalledWith( expect.objectContaining({ timeout: 12345 }), ); }); it('should use default timeout for connection when not configured', async () => { mockedLoadSettings.mockReturnValue( createMockSettings({ mcpServers: { 'test-server': { command: '/test/server' }, }, isTrusted: true, }), ); mockClient.connect.mockResolvedValue(undefined); await listMcpServers(); expect(mockClient.connect).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ timeout: 5000 }), ); expect(mockClient.ping).toHaveBeenCalledWith( expect.objectContaining({ timeout: 5000 }), ); }); it('should merge extension servers with config servers', async () => { mockedLoadSettings.mockReturnValue( createMockSettings({ mcpServers: { 'config-server': { command: '/config/server' }, }, isTrusted: true, }), ); mockExtensionManager.loadExtensions.mockReturnValue([ { name: 'test-extension', mcpServers: { 'extension-server': { command: '/ext/server' } }, }, ]); mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'config-server: /config/server (stdio) - Connected', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'extension-server (from test-extension): /ext/server (stdio) - Connected', ), ); }); it('should filter servers based on admin allowlist passed in settings', async () => { const adminControls = { strictModeDisabled: true, mcpSetting: { mcpEnabled: true, mcpConfig: { mcpServers: { 'allowed-server': { url: 'http://allowed' }, }, }, }, }; const mcpServers = { 'allowed-server': { command: 'cmd1' }, 'forbidden-server': { command: 'cmd2' }, }; const mockSettings = createMockSettings({ mcpServers, isTrusted: true, }); // setRemoteAdminSettings is the correct way to set admin settings in tests ( mockSettings as unknown as { setRemoteAdminSettings: (controls: AdminControlsSettings) => void; } ).setRemoteAdminSettings(adminControls as unknown as AdminControlsSettings); mockedLoadSettings.mockReturnValue(mockSettings); mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); await listMcpServers(mockSettings); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('allowed-server'), ); expect(debugLogger.log).not.toHaveBeenCalledWith( expect.stringContaining('forbidden-server'), ); expect(mockedCreateTransport).toHaveBeenCalledWith( 'allowed-server', expect.objectContaining({ url: 'http://allowed' }), // Should use admin config false, expect.anything(), ); }); it('should show stdio servers as disabled in untrusted folders', async () => { mockedLoadSettings.mockReturnValue( createMockSettings({ mcpServers: { 'test-server': { command: '/test/server' }, }, isTrusted: false, }), ); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'Warning: MCP servers are configured but disabled because this folder is untrusted.', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('test-server: /test/server (stdio) - Disabled'), ); }); it('should display blocked status for servers in excluded list', async () => { mockedLoadSettings.mockReturnValue( createMockSettings({ mcp: { excluded: ['blocked-server'], }, mcpServers: { 'blocked-server': { command: '/test/server' }, }, isTrusted: true, }), ); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'blocked-server: /test/server (stdio) - Blocked', ), ); expect(mockedCreateTransport).not.toHaveBeenCalled(); }); it('should display disabled status for servers disabled via enablement manager', async () => { mockedLoadSettings.mockReturnValue( createMockSettings({ mcpServers: { 'disabled-server': { command: '/test/server' }, }, isTrusted: true, }), ); vi.spyOn( McpServerEnablementManager.prototype, 'isFileEnabled', ).mockResolvedValue(false); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'disabled-server: /test/server (stdio) - Disabled', ), ); expect(mockedCreateTransport).not.toHaveBeenCalled(); }); it('should display warning and disabled status in untrusted folders', async () => { const userMcpServers = { 'user-server': { url: 'https://example.com/user' }, }; const workspaceMcpServers = { 'project-server': { command: '/path/to/project/server' }, }; mockedLoadSettings.mockReturnValue( createMockSettings({ user: { settings: { mcpServers: userMcpServers }, originalSettings: { mcpServers: userMcpServers }, path: '/mock/user/settings.json', }, workspace: { settings: { mcpServers: workspaceMcpServers }, originalSettings: { mcpServers: workspaceMcpServers }, path: '/mock/workspace/settings.json', }, isTrusted: false, }), ); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'Warning: MCP servers are configured but disabled because this folder is untrusted.', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'project-server: /path/to/project/server (stdio) - Disabled', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'user-server: https://example.com/user (http) - Disabled', ), ); expect(mockedCreateTransport).not.toHaveBeenCalled(); }); it('should block servers excluded by user settings even if workspace settings override/clear the excluded list', async () => { const mockSettings = createMockSettings({ user: { path: '/user/settings.json', settings: { mcp: { excluded: ['blocked-server'], }, }, originalSettings: { mcp: { excluded: ['blocked-server'], }, }, }, workspace: { path: '/workspace/settings.json', settings: { mcp: { excluded: [], }, }, originalSettings: { mcp: { excluded: [], }, }, }, mcpServers: { 'blocked-server': { command: '/test/server' }, }, isTrusted: true, merged: { mcp: { excluded: [], // workspace has overridden user settings! }, mcpServers: { 'blocked-server': { command: '/test/server' }, }, }, }); mockedLoadSettings.mockReturnValue(mockSettings); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'blocked-server: /test/server (stdio) - Blocked', ), ); expect(mockedCreateTransport).not.toHaveBeenCalled(); }); it('should block servers case-insensitively when excluded', async () => { const mockSettings = createMockSettings({ user: { path: '/user/settings.json', settings: { mcp: { excluded: ['BLOCKED-server'], }, }, originalSettings: { mcp: { excluded: ['BLOCKED-server'], }, }, }, mcpServers: { 'blocked-server': { command: '/test/server' }, }, isTrusted: true, merged: { mcpServers: { 'blocked-server': { command: '/test/server' }, }, }, }); mockedLoadSettings.mockReturnValue(mockSettings); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'blocked-server: /test/server (stdio) - Blocked', ), ); expect(mockedCreateTransport).not.toHaveBeenCalled(); }); it('should restrict allowed servers to the intersection of all defined allowlists', async () => { const mockSettings = createMockSettings({ user: { path: '/user/settings.json', settings: { mcp: { allowed: ['allowed-server-1', 'allowed-server-2'], }, }, originalSettings: { mcp: { allowed: ['allowed-server-1', 'allowed-server-2'], }, }, }, workspace: { path: '/workspace/settings.json', settings: { mcp: { allowed: ['allowed-server-1', 'malicious-server'], }, }, originalSettings: { mcp: { allowed: ['allowed-server-1', 'malicious-server'], }, }, }, mcpServers: { 'allowed-server-1': { command: '/allowed/1' }, 'allowed-server-2': { command: '/allowed/2' }, 'malicious-server': { command: '/malicious' }, }, isTrusted: true, merged: { mcp: { allowed: ['allowed-server-1', 'malicious-server'], // workspace overrode user settings! }, mcpServers: { 'allowed-server-1': { command: '/allowed/1' }, 'allowed-server-2': { command: '/allowed/2' }, 'malicious-server': { command: '/malicious' }, }, }, }); mockedLoadSettings.mockReturnValue(mockSettings); mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); await listMcpServers(); // allowed-server-1 is in the intersection, so it should connect expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'allowed-server-1: /allowed/1 (stdio) - Connected', ), ); // allowed-server-2 and malicious-server are not in the intersection, so they should be Blocked expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'allowed-server-2: /allowed/2 (stdio) - Blocked', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'malicious-server: /malicious (stdio) - Blocked', ), ); expect(mockedCreateTransport).toHaveBeenCalledTimes(1); expect(mockedCreateTransport).toHaveBeenCalledWith( 'allowed-server-1', expect.any(Object), false, expect.any(Object), ); }); it('should block all servers if the intersection of user and workspace allowlists is empty (disjoint allowlists)', async () => { const mockSettings = createMockSettings({ user: { path: '/user/settings.json', settings: { mcp: { allowed: ['user-allowed-server'], }, }, originalSettings: { mcp: { allowed: ['user-allowed-server'], }, }, }, workspace: { path: '/workspace/settings.json', settings: { mcp: { allowed: ['workspace-allowed-server'], }, }, originalSettings: { mcp: { allowed: ['workspace-allowed-server'], }, }, }, mcpServers: { 'user-allowed-server': { command: '/allowed/user' }, 'workspace-allowed-server': { command: '/allowed/workspace' }, }, isTrusted: true, merged: { mcp: { allowed: ['workspace-allowed-server'], // workspace override }, mcpServers: { 'user-allowed-server': { command: '/allowed/user' }, 'workspace-allowed-server': { command: '/allowed/workspace' }, }, }, }); mockedLoadSettings.mockReturnValue(mockSettings); await listMcpServers(); // Since the intersection is empty ([]), both servers should be Blocked! expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'user-allowed-server: /allowed/user (stdio) - Blocked', ), ); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'workspace-allowed-server: /allowed/workspace (stdio) - Blocked', ), ); expect(mockedCreateTransport).not.toHaveBeenCalled(); }); it('should block all servers if allowlist is configured as empty array []', async () => { const mockSettings = createMockSettings({ mcp: { allowed: [], // empty allowlist configured! }, mcpServers: { 'test-server': { command: '/test/server' }, }, isTrusted: true, }); mockedLoadSettings.mockReturnValue(mockSettings); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining('test-server: /test/server (stdio) - Blocked'), ); expect(mockedCreateTransport).not.toHaveBeenCalled(); }); });