/** * @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, mergeSettings } from '../../config/settings.js'; import { createTransport, debugLogger } 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'; 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', }, 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', }, ), 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(() => {}); 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 () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcpServers: {} }, }); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith('No MCP servers configured.'); }); it('should display different server types with connected status', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, 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 () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcpServers: { 'test-server': { command: '/test/server' }, }, }, }); mockClient.connect.mockRejectedValue(new Error('Connection failed')); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'test-server: /test/server (stdio) - Disconnected', ), ); }); it('should merge extension servers with config servers', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, 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 settingsWithAllowlist = mergeSettings({}, {}, {}, {}, true); settingsWithAllowlist.admin = { secureModeEnabled: false, extensions: { enabled: true }, skills: { enabled: true }, mcp: { enabled: true, config: { 'allowed-server': { url: 'http://allowed' }, }, }, }; settingsWithAllowlist.mcpServers = { 'allowed-server': { command: 'cmd1' }, 'forbidden-server': { command: 'cmd2' }, }; mockedLoadSettings.mockReturnValue({ merged: settingsWithAllowlist, }); mockClient.connect.mockResolvedValue(undefined); mockClient.ping.mockResolvedValue(undefined); await listMcpServers(settingsWithAllowlist); 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 disconnected in untrusted folders', async () => { const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); mockedLoadSettings.mockReturnValue({ merged: { ...defaultMergedSettings, mcpServers: { 'test-server': { command: '/test/server' }, }, }, isTrusted: false, }); // createTransport will throw in core if not trusted mockedCreateTransport.mockRejectedValue(new Error('Folder not trusted')); await listMcpServers(); expect(debugLogger.log).toHaveBeenCalledWith( expect.stringContaining( 'test-server: /test/server (stdio) - Disconnected', ), ); }); });