From a060e6149ad57c685d0d94643f25408658586baf Mon Sep 17 00:00:00 2001 From: Jasmeet Bhatia Date: Thu, 22 Jan 2026 15:38:06 -0800 Subject: [PATCH] feat(mcp): add enable/disable commands for MCP servers (#11057) (#16299) Co-authored-by: Allen Hutchison --- docs/tools/mcp-server.md | 23 ++ packages/cli/src/commands/mcp.test.ts | 4 +- packages/cli/src/commands/mcp.ts | 3 + .../cli/src/commands/mcp/enableDisable.ts | 169 +++++++++ packages/cli/src/commands/mcp/list.ts | 2 +- packages/cli/src/config/config.ts | 8 + packages/cli/src/config/mcp/index.ts | 17 + .../config/mcp/mcpServerEnablement.test.ts | 188 +++++++++ .../cli/src/config/mcp/mcpServerEnablement.ts | 357 ++++++++++++++++++ packages/cli/src/ui/commands/mcpCommand.ts | 167 ++++++++ .../ui/components/views/McpStatus.test.tsx | 7 + .../cli/src/ui/components/views/McpStatus.tsx | 48 ++- packages/cli/src/ui/types.ts | 8 + packages/core/src/config/config.ts | 19 + .../core/src/tools/mcp-client-manager.test.ts | 1 + packages/core/src/tools/mcp-client-manager.ts | 95 +++-- 16 files changed, 1068 insertions(+), 48 deletions(-) create mode 100644 packages/cli/src/commands/mcp/enableDisable.ts create mode 100644 packages/cli/src/config/mcp/index.ts create mode 100644 packages/cli/src/config/mcp/mcpServerEnablement.test.ts create mode 100644 packages/cli/src/config/mcp/mcpServerEnablement.ts diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index e66d1db0ad..f6f14354b2 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -1038,6 +1038,29 @@ gemini mcp remove my-server This will find and delete the "my-server" entry from the `mcpServers` object in the appropriate `settings.json` file based on the scope (`-s, --scope`). +### Enabling/disabling a server (`gemini mcp enable`, `gemini mcp disable`) + +Temporarily disable an MCP server without removing its configuration, or +re-enable a previously disabled server. + +**Commands:** + +```bash +gemini mcp enable [--session] +gemini mcp disable [--session] +``` + +**Options (flags):** + +- `--session`: Apply change only for this session (not persisted to file). + +Disabled servers appear in `/mcp` status as "Disabled" but won't connect or +provide tools. Enablement state is stored in +`~/.gemini/mcp-server-enablement.json`. + +The same commands are available as slash commands during an active session: +`/mcp enable ` and `/mcp disable `. + ## Instructions Gemini CLI supports diff --git a/packages/cli/src/commands/mcp.test.ts b/packages/cli/src/commands/mcp.test.ts index 4e476ddad6..2877f84714 100644 --- a/packages/cli/src/commands/mcp.test.ts +++ b/packages/cli/src/commands/mcp.test.ts @@ -61,7 +61,7 @@ describe('mcp command', () => { (mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv); - expect(mockYargs.command).toHaveBeenCalledTimes(3); + expect(mockYargs.command).toHaveBeenCalledTimes(5); // Verify that the specific subcommands are registered const commandCalls = mockYargs.command.mock.calls; @@ -70,6 +70,8 @@ describe('mcp command', () => { expect(commandNames).toContain('add [args...]'); expect(commandNames).toContain('remove '); expect(commandNames).toContain('list'); + expect(commandNames).toContain('enable '); + expect(commandNames).toContain('disable '); expect(mockYargs.demandCommand).toHaveBeenCalledWith( 1, diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 8f12fc50fd..d2b7f85f03 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -9,6 +9,7 @@ import type { CommandModule, Argv } from 'yargs'; import { addCommand } from './mcp/add.js'; import { removeCommand } from './mcp/remove.js'; import { listCommand } from './mcp/list.js'; +import { enableCommand, disableCommand } from './mcp/enableDisable.js'; import { initializeOutputListenersAndFlush } from '../gemini.js'; import { defer } from '../deferred.js'; @@ -24,6 +25,8 @@ export const mcpCommand: CommandModule = { .command(defer(addCommand, 'mcp')) .command(defer(removeCommand, 'mcp')) .command(defer(listCommand, 'mcp')) + .command(defer(enableCommand, 'mcp')) + .command(defer(disableCommand, 'mcp')) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => { diff --git a/packages/cli/src/commands/mcp/enableDisable.ts b/packages/cli/src/commands/mcp/enableDisable.ts new file mode 100644 index 0000000000..f4146897eb --- /dev/null +++ b/packages/cli/src/commands/mcp/enableDisable.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule } from 'yargs'; +import { debugLogger } from '@google/gemini-cli-core'; +import { + McpServerEnablementManager, + canLoadServer, + normalizeServerId, +} from '../../config/mcp/mcpServerEnablement.js'; +import { loadSettings } from '../../config/settings.js'; +import { exitCli } from '../utils.js'; +import { getMcpServersFromConfig } from './list.js'; + +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const RED = '\x1b[31m'; +const RESET = '\x1b[0m'; + +interface Args { + name: string; + session?: boolean; +} + +async function handleEnable(args: Args): Promise { + const manager = McpServerEnablementManager.getInstance(); + const name = normalizeServerId(args.name); + + // Check settings blocks + const settings = loadSettings(); + + // Get all servers including extensions + const servers = await getMcpServersFromConfig(); + const normalizedServerNames = Object.keys(servers).map(normalizeServerId); + if (!normalizedServerNames.includes(name)) { + debugLogger.log( + `${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`, + ); + return; + } + + // Check if server is from an extension + const serverKey = Object.keys(servers).find( + (key) => normalizeServerId(key) === name, + ); + const server = serverKey ? servers[serverKey] : undefined; + if (server?.extension) { + debugLogger.log( + `${RED}Error:${RESET} Server '${args.name}' is provided by extension '${server.extension.name}'.`, + ); + debugLogger.log( + `Use 'gemini extensions enable ${server.extension.name}' to manage this extension.`, + ); + return; + } + + const result = await canLoadServer(name, { + adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true, + allowedList: settings.merged.mcp?.allowed, + excludedList: settings.merged.mcp?.excluded, + }); + + if ( + !result.allowed && + (result.blockType === 'allowlist' || result.blockType === 'excludelist') + ) { + debugLogger.log(`${RED}Error:${RESET} ${result.reason}`); + return; + } + + if (args.session) { + manager.clearSessionDisable(name); + debugLogger.log(`${GREEN}βœ“${RESET} Session disable cleared for '${name}'.`); + } else { + await manager.enable(name); + debugLogger.log(`${GREEN}βœ“${RESET} MCP server '${name}' enabled.`); + } + + if (result.blockType === 'admin') { + debugLogger.log( + `${YELLOW}Warning:${RESET} MCP servers are disabled by administrator.`, + ); + } +} + +async function handleDisable(args: Args): Promise { + const manager = McpServerEnablementManager.getInstance(); + const name = normalizeServerId(args.name); + + // Get all servers including extensions + const servers = await getMcpServersFromConfig(); + const normalizedServerNames = Object.keys(servers).map(normalizeServerId); + if (!normalizedServerNames.includes(name)) { + debugLogger.log( + `${RED}Error:${RESET} Server '${args.name}' not found. Use 'gemini mcp' to see available servers.`, + ); + return; + } + + // Check if server is from an extension + const serverKey = Object.keys(servers).find( + (key) => normalizeServerId(key) === name, + ); + const server = serverKey ? servers[serverKey] : undefined; + if (server?.extension) { + debugLogger.log( + `${RED}Error:${RESET} Server '${args.name}' is provided by extension '${server.extension.name}'.`, + ); + debugLogger.log( + `Use 'gemini extensions disable ${server.extension.name}' to manage this extension.`, + ); + return; + } + + if (args.session) { + manager.disableForSession(name); + debugLogger.log( + `${GREEN}βœ“${RESET} MCP server '${name}' disabled for this session.`, + ); + } else { + await manager.disable(name); + debugLogger.log(`${GREEN}βœ“${RESET} MCP server '${name}' disabled.`); + } +} + +export const enableCommand: CommandModule = { + command: 'enable ', + describe: 'Enable an MCP server', + builder: (yargs) => + yargs + .positional('name', { + describe: 'MCP server name to enable', + type: 'string', + demandOption: true, + }) + .option('session', { + describe: 'Clear session-only disable', + type: 'boolean', + default: false, + }), + handler: async (argv) => { + await handleEnable(argv as Args); + await exitCli(); + }, +}; + +export const disableCommand: CommandModule = { + command: 'disable ', + describe: 'Disable an MCP server', + builder: (yargs) => + yargs + .positional('name', { + describe: 'MCP server name to disable', + type: 'string', + demandOption: true, + }) + .option('session', { + describe: 'Disable for current session only', + type: 'boolean', + default: false, + }), + handler: async (argv) => { + await handleDisable(argv as Args); + await exitCli(); + }, +}; diff --git a/packages/cli/src/commands/mcp/list.ts b/packages/cli/src/commands/mcp/list.ts index 86fbbb9b1e..50fc222f71 100644 --- a/packages/cli/src/commands/mcp/list.ts +++ b/packages/cli/src/commands/mcp/list.ts @@ -24,7 +24,7 @@ const COLOR_YELLOW = '\u001b[33m'; const COLOR_RED = '\u001b[31m'; const RESET_COLOR = '\u001b[0m'; -async function getMcpServersFromConfig(): Promise< +export async function getMcpServersFromConfig(): Promise< Record > { const settings = loadSettings(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index efc1616300..d6ac10bc77 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -53,6 +53,7 @@ import { RESUME_LATEST } from '../utils/sessionUtils.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { createPolicyEngineConfig } from './policy.js'; import { ExtensionManager } from './extension-manager.js'; +import { McpServerEnablementManager } from './mcp/mcpServerEnablement.js'; import type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js'; import { requestConsentNonInteractive } from './extensions/consent.js'; import { promptForSetting } from './extensions/extensionSettings.js'; @@ -665,6 +666,12 @@ export async function loadCliConfig( const extensionsEnabled = settings.admin?.extensions?.enabled ?? true; const adminSkillsEnabled = settings.admin?.skills?.enabled ?? true; + // Create MCP enablement manager and callbacks + const mcpEnablementManager = McpServerEnablementManager.getInstance(); + const mcpEnablementCallbacks = mcpEnabled + ? mcpEnablementManager.getEnablementCallbacks() + : undefined; + return new Config({ sessionId, clientVersion: await getVersion(), @@ -686,6 +693,7 @@ export async function loadCliConfig( toolCallCommand: settings.tools?.callCommand, mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined, mcpServers: mcpEnabled ? settings.mcpServers : {}, + mcpEnablementCallbacks, mcpEnabled, extensionsEnabled, agents: settings.agents, diff --git a/packages/cli/src/config/mcp/index.ts b/packages/cli/src/config/mcp/index.ts new file mode 100644 index 0000000000..555f52071e --- /dev/null +++ b/packages/cli/src/config/mcp/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + McpServerEnablementManager, + canLoadServer, + normalizeServerId, + isInSettingsList, + type McpServerEnablementState, + type McpServerEnablementConfig, + type McpServerDisplayState, + type EnablementCallbacks, + type ServerLoadResult, +} from './mcpServerEnablement.js'; diff --git a/packages/cli/src/config/mcp/mcpServerEnablement.test.ts b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts new file mode 100644 index 0000000000..8b41324790 --- /dev/null +++ b/packages/cli/src/config/mcp/mcpServerEnablement.test.ts @@ -0,0 +1,188 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Storage: { + ...actual.Storage, + getGlobalGeminiDir: () => '/virtual-home/.gemini', + }, + }; +}); + +import { + McpServerEnablementManager, + canLoadServer, + normalizeServerId, + isInSettingsList, + type EnablementCallbacks, +} from './mcpServerEnablement.js'; + +let inMemoryFs: Record = {}; + +function createMockEnablement( + sessionDisabled: boolean, + fileEnabled: boolean, +): EnablementCallbacks { + return { + isSessionDisabled: () => sessionDisabled, + isFileEnabled: () => Promise.resolve(fileEnabled), + }; +} + +function setupFsMocks(): void { + vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => { + const content = inMemoryFs[filePath.toString()]; + if (content === undefined) { + const error = new Error(`ENOENT: ${filePath}`); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + throw error; + } + return content; + }); + vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath, data) => { + inMemoryFs[filePath.toString()] = data.toString(); + }); + vi.spyOn(fs, 'mkdir').mockImplementation(async () => undefined); +} + +describe('McpServerEnablementManager', () => { + let manager: McpServerEnablementManager; + + beforeEach(() => { + inMemoryFs = {}; + setupFsMocks(); + McpServerEnablementManager.resetInstance(); + manager = McpServerEnablementManager.getInstance(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + McpServerEnablementManager.resetInstance(); + }); + + it('should enable/disable servers with persistence', async () => { + expect(await manager.isFileEnabled('server')).toBe(true); + await manager.disable('server'); + expect(await manager.isFileEnabled('server')).toBe(false); + await manager.enable('server'); + expect(await manager.isFileEnabled('server')).toBe(true); + }); + + it('should handle session disable separately', async () => { + manager.disableForSession('server'); + expect(manager.isSessionDisabled('server')).toBe(true); + expect(await manager.isFileEnabled('server')).toBe(true); + expect(await manager.isEffectivelyEnabled('server')).toBe(false); + manager.clearSessionDisable('server'); + expect(await manager.isEffectivelyEnabled('server')).toBe(true); + }); + + it('should be case-insensitive', async () => { + await manager.disable('PlayWright'); + expect(await manager.isFileEnabled('playwright')).toBe(false); + }); + + it('should return correct display state', async () => { + await manager.disable('file-disabled'); + manager.disableForSession('session-disabled'); + + expect(await manager.getDisplayState('enabled')).toEqual({ + enabled: true, + isSessionDisabled: false, + isPersistentDisabled: false, + }); + expect( + (await manager.getDisplayState('file-disabled')).isPersistentDisabled, + ).toBe(true); + expect( + (await manager.getDisplayState('session-disabled')).isSessionDisabled, + ).toBe(true); + }); + + it('should share session state across getInstance calls', () => { + const instance1 = McpServerEnablementManager.getInstance(); + const instance2 = McpServerEnablementManager.getInstance(); + + instance1.disableForSession('test-server'); + + expect(instance2.isSessionDisabled('test-server')).toBe(true); + expect(instance1).toBe(instance2); + }); +}); + +describe('canLoadServer', () => { + it('blocks when admin has disabled MCP', async () => { + const result = await canLoadServer('s', { adminMcpEnabled: false }); + expect(result.blockType).toBe('admin'); + }); + + it('blocks when server is not in allowlist', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + allowedList: ['other'], + }); + expect(result.blockType).toBe('allowlist'); + }); + + it('blocks when server is in excludelist', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + excludedList: ['s'], + }); + expect(result.blockType).toBe('excludelist'); + }); + + it('blocks when server is session-disabled', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + enablement: createMockEnablement(true, true), + }); + expect(result.blockType).toBe('session'); + }); + + it('blocks when server is file-disabled', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + enablement: createMockEnablement(false, false), + }); + expect(result.blockType).toBe('enablement'); + }); + + it('allows when admin MCP is enabled and no restrictions', async () => { + const result = await canLoadServer('s', { adminMcpEnabled: true }); + expect(result.allowed).toBe(true); + }); + + it('allows when server passes all checks', async () => { + const result = await canLoadServer('s', { + adminMcpEnabled: true, + allowedList: ['s'], + enablement: createMockEnablement(false, true), + }); + expect(result.allowed).toBe(true); + }); +}); + +describe('helper functions', () => { + it('normalizeServerId lowercases and trims', () => { + expect(normalizeServerId(' PlayWright ')).toBe('playwright'); + }); + + it('isInSettingsList supports ext: backward compat', () => { + expect(isInSettingsList('playwright', ['playwright']).found).toBe(true); + expect(isInSettingsList('ext:github:mcp', ['mcp']).found).toBe(true); + expect( + isInSettingsList('ext:github:mcp', ['mcp']).deprecationWarning, + ).toBeTruthy(); + }); +}); diff --git a/packages/cli/src/config/mcp/mcpServerEnablement.ts b/packages/cli/src/config/mcp/mcpServerEnablement.ts new file mode 100644 index 0000000000..da8a7a92a8 --- /dev/null +++ b/packages/cli/src/config/mcp/mcpServerEnablement.ts @@ -0,0 +1,357 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { Storage, coreEvents } from '@google/gemini-cli-core'; + +/** + * Stored in JSON file - represents persistent enablement state. + */ +export interface McpServerEnablementState { + enabled: boolean; +} + +/** + * File config format - map of server ID to enablement state. + */ +export interface McpServerEnablementConfig { + [serverId: string]: McpServerEnablementState; +} + +/** + * For UI display - combines file and session state. + */ +export interface McpServerDisplayState { + /** Effective state (considering session override) */ + enabled: boolean; + /** True if disabled via --session flag */ + isSessionDisabled: boolean; + /** True if disabled in file */ + isPersistentDisabled: boolean; +} + +/** + * Callback types for enablement checks (passed from CLI to core). + */ +export interface EnablementCallbacks { + isSessionDisabled: (serverId: string) => boolean; + isFileEnabled: (serverId: string) => Promise; +} + +/** + * Result of canLoadServer check. + */ +export interface ServerLoadResult { + allowed: boolean; + reason?: string; + blockType?: 'admin' | 'allowlist' | 'excludelist' | 'session' | 'enablement'; +} + +/** + * Normalize a server ID to canonical lowercase form. + */ +export function normalizeServerId(serverId: string): string { + return serverId.toLowerCase().trim(); +} + +/** + * Check if a server ID is in a settings list (with backward compatibility). + * Handles case-insensitive matching and plain name fallback for ext: servers. + */ +export function isInSettingsList( + serverId: string, + list: string[], +): { found: boolean; deprecationWarning?: string } { + const normalizedId = normalizeServerId(serverId); + const normalizedList = list.map(normalizeServerId); + + // Exact canonical match + if (normalizedList.includes(normalizedId)) { + return { found: true }; + } + + // Backward compat: for ext: servers, check if plain name matches + if (normalizedId.startsWith('ext:')) { + const plainName = normalizedId.split(':').pop(); + if (plainName && normalizedList.includes(plainName)) { + return { + found: true, + deprecationWarning: + `Settings reference '${plainName}' matches extension server '${serverId}'. ` + + `Update your settings to use the full identifier '${serverId}' instead.`, + }; + } + } + + return { found: false }; +} + +/** + * Single source of truth for whether a server can be loaded. + * Used by: isAllowedMcpServer(), connectServer(), CLI handlers, slash handlers. + * + * Uses callbacks instead of direct enablementManager reference to keep + * packages/core independent of packages/cli. + */ +export async function canLoadServer( + serverId: string, + config: { + adminMcpEnabled: boolean; + allowedList?: string[]; + excludedList?: string[]; + enablement?: EnablementCallbacks; + }, +): Promise { + const normalizedId = normalizeServerId(serverId); + + // 1. Admin kill switch + if (!config.adminMcpEnabled) { + return { + allowed: false, + reason: + 'MCP servers are disabled by administrator. Check admin settings or contact your admin.', + blockType: 'admin', + }; + } + + // 2. Allowlist check + if (config.allowedList && config.allowedList.length > 0) { + const { found, deprecationWarning } = isInSettingsList( + normalizedId, + config.allowedList, + ); + if (deprecationWarning) { + coreEvents.emitFeedback('warning', deprecationWarning); + } + if (!found) { + return { + allowed: false, + reason: `Server '${serverId}' is not in mcp.allowed list. Add it to settings.json mcp.allowed array to enable.`, + blockType: 'allowlist', + }; + } + } + + // 3. Excludelist check + if (config.excludedList) { + const { found, deprecationWarning } = isInSettingsList( + normalizedId, + config.excludedList, + ); + if (deprecationWarning) { + coreEvents.emitFeedback('warning', deprecationWarning); + } + if (found) { + return { + allowed: false, + reason: `Server '${serverId}' is blocked by mcp.excluded. Remove it from settings.json mcp.excluded array to enable.`, + blockType: 'excludelist', + }; + } + } + + // 4. Session disable check (before file-based enablement) + if (config.enablement?.isSessionDisabled(normalizedId)) { + return { + allowed: false, + reason: `Server '${serverId}' is disabled for this session. Run 'gemini mcp enable ${serverId} --session' to clear.`, + blockType: 'session', + }; + } + + // 5. File-based enablement check + if ( + config.enablement && + !(await config.enablement.isFileEnabled(normalizedId)) + ) { + return { + allowed: false, + reason: `Server '${serverId}' is disabled. Run 'gemini mcp enable ${serverId}' to enable.`, + blockType: 'enablement', + }; + } + + return { allowed: true }; +} + +const MCP_ENABLEMENT_FILENAME = 'mcp-server-enablement.json'; + +/** + * McpServerEnablementManager + * + * Manages the enabled/disabled state of MCP servers. + * Uses a simplified format compared to ExtensionEnablementManager. + * Supports both persistent (file) and session-only (in-memory) states. + * + * NOTE: Use getInstance() to get the singleton instance. This ensures + * session state (sessionDisabled Set) is shared across all code paths. + */ +export class McpServerEnablementManager { + private static instance: McpServerEnablementManager | null = null; + + private readonly configFilePath: string; + private readonly configDir: string; + private readonly sessionDisabled = new Set(); + + /** + * Get the singleton instance. + */ + static getInstance(): McpServerEnablementManager { + if (!McpServerEnablementManager.instance) { + McpServerEnablementManager.instance = new McpServerEnablementManager(); + } + return McpServerEnablementManager.instance; + } + + /** + * Reset the singleton instance (for testing only). + */ + static resetInstance(): void { + McpServerEnablementManager.instance = null; + } + + constructor() { + this.configDir = Storage.getGlobalGeminiDir(); + this.configFilePath = path.join(this.configDir, MCP_ENABLEMENT_FILENAME); + } + + /** + * Check if server is enabled in FILE (persistent config only). + * Does NOT include session state. + */ + async isFileEnabled(serverName: string): Promise { + const config = await this.readConfig(); + const state = config[normalizeServerId(serverName)]; + return state?.enabled ?? true; + } + + /** + * Check if server is session-disabled. + */ + isSessionDisabled(serverName: string): boolean { + return this.sessionDisabled.has(normalizeServerId(serverName)); + } + + /** + * Check effective enabled state (combines file + session). + * Convenience method; canLoadServer() uses separate callbacks for granular blockType. + */ + async isEffectivelyEnabled(serverName: string): Promise { + if (this.isSessionDisabled(serverName)) { + return false; + } + return this.isFileEnabled(serverName); + } + + /** + * Enable a server persistently. + * Removes the server from config file (defaults to enabled). + */ + async enable(serverName: string): Promise { + const normalizedName = normalizeServerId(serverName); + const config = await this.readConfig(); + + if (normalizedName in config) { + delete config[normalizedName]; + await this.writeConfig(config); + } + } + + /** + * Disable a server persistently. + * Adds server to config file with enabled: false. + */ + async disable(serverName: string): Promise { + const config = await this.readConfig(); + config[normalizeServerId(serverName)] = { enabled: false }; + await this.writeConfig(config); + } + + /** + * Disable a server for current session only (in-memory). + */ + disableForSession(serverName: string): void { + this.sessionDisabled.add(normalizeServerId(serverName)); + } + + /** + * Clear session disable for a server. + */ + clearSessionDisable(serverName: string): void { + this.sessionDisabled.delete(normalizeServerId(serverName)); + } + + /** + * Get display state for a specific server (for UI). + */ + async getDisplayState(serverName: string): Promise { + const isSessionDisabled = this.isSessionDisabled(serverName); + const isPersistentDisabled = !(await this.isFileEnabled(serverName)); + + return { + enabled: !isSessionDisabled && !isPersistentDisabled, + isSessionDisabled, + isPersistentDisabled, + }; + } + + /** + * Get all display states (for UI listing). + */ + async getAllDisplayStates( + serverIds: string[], + ): Promise> { + const result: Record = {}; + for (const serverId of serverIds) { + result[normalizeServerId(serverId)] = + await this.getDisplayState(serverId); + } + return result; + } + + /** + * Get enablement callbacks for passing to core. + */ + getEnablementCallbacks(): EnablementCallbacks { + return { + isSessionDisabled: (id) => this.isSessionDisabled(id), + isFileEnabled: (id) => this.isFileEnabled(id), + }; + } + + /** + * Read config from file asynchronously. + */ + private async readConfig(): Promise { + try { + const content = await fs.readFile(this.configFilePath, 'utf-8'); + return JSON.parse(content) as McpServerEnablementConfig; + } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + return {}; + } + coreEvents.emitFeedback( + 'error', + 'Failed to read MCP server enablement config.', + error, + ); + return {}; + } + } + + /** + * Write config to file asynchronously. + */ + private async writeConfig(config: McpServerEnablementConfig): Promise { + await fs.mkdir(this.configDir, { recursive: true }); + await fs.writeFile(this.configFilePath, JSON.stringify(config, null, 2)); + } +} diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index b0d95bd603..97ac6973a6 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -23,6 +23,12 @@ import { } from '@google/gemini-cli-core'; import { appEvents, AppEvent } from '../../utils/events.js'; import { MessageType, type HistoryItemMcpStatus } from '../types.js'; +import { + McpServerEnablementManager, + normalizeServerId, + canLoadServer, +} from '../../config/mcp/mcpServerEnablement.js'; +import { loadSettings } from '../../config/settings.js'; const authCommand: SlashCommand = { name: 'auth', @@ -241,6 +247,14 @@ const listAction = async ( } } + // Get enablement state for all servers + const enablementManager = McpServerEnablementManager.getInstance(); + const enablementState: HistoryItemMcpStatus['enablementState'] = {}; + for (const serverName of serverNames) { + enablementState[serverName] = + await enablementManager.getDisplayState(serverName); + } + const mcpStatusItem: HistoryItemMcpStatus = { type: MessageType.MCP_STATUS, servers: mcpServers, @@ -263,6 +277,7 @@ const listAction = async ( description: resource.description, })), authStatus, + enablementState, blockedServers: blockedMcpServers, discoveryInProgress, connectingServers, @@ -346,6 +361,156 @@ const refreshCommand: SlashCommand = { }, }; +async function handleEnableDisable( + context: CommandContext, + args: string, + enable: boolean, +): Promise { + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Config not loaded.', + }; + } + + const parts = args.trim().split(/\s+/); + const isSession = parts.includes('--session'); + const serverName = parts.filter((p) => p !== '--session')[0]; + const action = enable ? 'enable' : 'disable'; + + if (!serverName) { + return { + type: 'message', + messageType: 'error', + content: `Server name required. Usage: /mcp ${action} [--session]`, + }; + } + + const name = normalizeServerId(serverName); + + // Validate server exists + const servers = config.getMcpClientManager()?.getMcpServers() || {}; + const normalizedServerNames = Object.keys(servers).map(normalizeServerId); + if (!normalizedServerNames.includes(name)) { + return { + type: 'message', + messageType: 'error', + content: `Server '${serverName}' not found. Use /mcp list to see available servers.`, + }; + } + + // Check if server is from an extension + const serverKey = Object.keys(servers).find( + (key) => normalizeServerId(key) === name, + ); + const server = serverKey ? servers[serverKey] : undefined; + if (server?.extension) { + return { + type: 'message', + messageType: 'error', + content: `Server '${serverName}' is provided by extension '${server.extension.name}'.\nUse '/extensions ${action} ${server.extension.name}' to manage this extension.`, + }; + } + + const manager = McpServerEnablementManager.getInstance(); + + if (enable) { + const settings = loadSettings(); + const result = await canLoadServer(name, { + adminMcpEnabled: settings.merged.admin?.mcp?.enabled ?? true, + allowedList: settings.merged.mcp?.allowed, + excludedList: settings.merged.mcp?.excluded, + }); + if ( + !result.allowed && + (result.blockType === 'allowlist' || result.blockType === 'excludelist') + ) { + return { + type: 'message', + messageType: 'error', + content: result.reason ?? 'Blocked by settings.', + }; + } + if (isSession) { + manager.clearSessionDisable(name); + } else { + await manager.enable(name); + } + if (result.blockType === 'admin') { + context.ui.addItem( + { + type: 'warning', + text: 'MCP disabled by admin. Will load when enabled.', + }, + Date.now(), + ); + } + } else { + if (isSession) { + manager.disableForSession(name); + } else { + await manager.disable(name); + } + } + + const msg = `MCP server '${name}' ${enable ? 'enabled' : 'disabled'}${isSession ? ' for this session' : ''}.`; + + const mcpClientManager = config.getMcpClientManager(); + if (mcpClientManager) { + context.ui.addItem( + { type: 'info', text: 'Restarting MCP servers...' }, + Date.now(), + ); + await mcpClientManager.restart(); + } + if (config.getGeminiClient()?.isInitialized()) + await config.getGeminiClient().setTools(); + context.ui.reloadCommands(); + + return { type: 'message', messageType: 'info', content: msg }; +} + +async function getEnablementCompletion( + context: CommandContext, + partialArg: string, + showEnabled: boolean, +): Promise { + const { config } = context.services; + if (!config) return []; + const servers = Object.keys( + config.getMcpClientManager()?.getMcpServers() || {}, + ); + const manager = McpServerEnablementManager.getInstance(); + const results: string[] = []; + for (const n of servers) { + const state = await manager.getDisplayState(n); + if (state.enabled === showEnabled && n.startsWith(partialArg)) { + results.push(n); + } + } + return results; +} + +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable a disabled MCP server', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (ctx, args) => handleEnableDisable(ctx, args, true), + completion: (ctx, arg) => getEnablementCompletion(ctx, arg, false), +}; + +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable an MCP server', + kind: CommandKind.BUILT_IN, + autoExecute: true, + action: (ctx, args) => handleEnableDisable(ctx, args, false), + completion: (ctx, arg) => getEnablementCompletion(ctx, arg, true), +}; + export const mcpCommand: SlashCommand = { name: 'mcp', description: 'Manage configured Model Context Protocol (MCP) servers', @@ -357,6 +522,8 @@ export const mcpCommand: SlashCommand = { schemaCommand, authCommand, refreshCommand, + enableCommand, + disableCommand, ], action: async (context: CommandContext) => listAction(context), }; diff --git a/packages/cli/src/ui/components/views/McpStatus.test.tsx b/packages/cli/src/ui/components/views/McpStatus.test.tsx index 8d448ff8f3..5ebba6359f 100644 --- a/packages/cli/src/ui/components/views/McpStatus.test.tsx +++ b/packages/cli/src/ui/components/views/McpStatus.test.tsx @@ -40,6 +40,13 @@ describe('McpStatus', () => { blockedServers: [], serverStatus: () => MCPServerStatus.CONNECTED, authStatus: {}, + enablementState: { + 'server-1': { + enabled: true, + isSessionDisabled: false, + isPersistentDisabled: false, + }, + }, discoveryInProgress: false, connectingServers: [], showDescriptions: true, diff --git a/packages/cli/src/ui/components/views/McpStatus.tsx b/packages/cli/src/ui/components/views/McpStatus.tsx index 0a3602cc3e..14ff7bdfc6 100644 --- a/packages/cli/src/ui/components/views/McpStatus.tsx +++ b/packages/cli/src/ui/components/views/McpStatus.tsx @@ -25,6 +25,7 @@ interface McpStatusProps { blockedServers: Array<{ name: string; extensionName: string }>; serverStatus: (serverName: string) => MCPServerStatus; authStatus: HistoryItemMcpStatus['authStatus']; + enablementState: HistoryItemMcpStatus['enablementState']; discoveryInProgress: boolean; connectingServers: string[]; showDescriptions: boolean; @@ -39,6 +40,7 @@ export const McpStatus: React.FC = ({ blockedServers, serverStatus, authStatus, + enablementState, discoveryInProgress, connectingServers, showDescriptions, @@ -104,23 +106,35 @@ export const McpStatus: React.FC = ({ 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; + // Check enablement state + const serverEnablement = enablementState[serverName]; + const isDisabled = serverEnablement && !serverEnablement.enabled; + + if (isDisabled) { + statusIndicator = '⏸️'; + statusText = serverEnablement.isSessionDisabled + ? 'Disabled (session)' + : 'Disabled'; + statusColor = theme.text.secondary; + } else { + 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; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 9442b44c51..dcadfbcffd 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -270,6 +270,14 @@ export type HistoryItemMcpStatus = HistoryItemBase & { string, 'authenticated' | 'expired' | 'unauthenticated' | 'not-configured' >; + enablementState: Record< + string, + { + enabled: boolean; + isSessionDisabled: boolean; + isPersistentDisabled: boolean; + } + >; blockedServers: Array<{ name: string; extensionName: string }>; discoveryInProgress: boolean; connectingServers: string[]; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index d8cca5b865..06806fe93e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -278,6 +278,18 @@ export interface SandboxConfig { image: string; } +/** + * Callbacks for checking MCP server enablement status. + * These callbacks are provided by the CLI package to bridge + * the enablement state to the core package. + */ +export interface McpEnablementCallbacks { + /** Check if a server is disabled for the current session only */ + isSessionDisabled: (serverId: string) => boolean; + /** Check if a server is enabled in the file-based configuration */ + isFileEnabled: (serverId: string) => Promise; +} + export interface ConfigParameters { sessionId: string; clientVersion?: string; @@ -294,6 +306,7 @@ export interface ConfigParameters { toolCallCommand?: string; mcpServerCommand?: string; mcpServers?: Record; + mcpEnablementCallbacks?: McpEnablementCallbacks; userMemory?: string; geminiMdFileCount?: number; geminiMdFilePaths?: string[]; @@ -426,6 +439,7 @@ export class Config { private readonly mcpEnabled: boolean; private readonly extensionsEnabled: boolean; private mcpServers: Record | undefined; + private readonly mcpEnablementCallbacks?: McpEnablementCallbacks; private userMemory: string; private geminiMdFileCount: number; private geminiMdFilePaths: string[]; @@ -564,6 +578,7 @@ export class Config { this.toolCallCommand = params.toolCallCommand; this.mcpServerCommand = params.mcpServerCommand; this.mcpServers = params.mcpServers; + this.mcpEnablementCallbacks = params.mcpEnablementCallbacks; this.mcpEnabled = params.mcpEnabled ?? true; this.extensionsEnabled = params.extensionsEnabled ?? true; this.allowedMcpServers = params.allowedMcpServers ?? []; @@ -1235,6 +1250,10 @@ export class Config { return this.mcpEnabled; } + getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined { + return this.mcpEnablementCallbacks; + } + getExtensionsEnabled(): boolean { return this.extensionsEnabled; } diff --git a/packages/core/src/tools/mcp-client-manager.test.ts b/packages/core/src/tools/mcp-client-manager.test.ts index 18b8ab3ff7..fbd4785e65 100644 --- a/packages/core/src/tools/mcp-client-manager.test.ts +++ b/packages/core/src/tools/mcp-client-manager.test.ts @@ -50,6 +50,7 @@ describe('McpClientManager', () => { getAllowedMcpServers: vi.fn().mockReturnValue([]), getBlockedMcpServers: vi.fn().mockReturnValue([]), getMcpServerCommand: vi.fn().mockReturnValue(''), + getMcpEnablementCallbacks: vi.fn().mockReturnValue(undefined), getGeminiClient: vi.fn().mockReturnValue({ isInitialized: vi.fn(), }), diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index e9407c1c7b..657699ca1c 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -27,6 +27,8 @@ import { debugLogger } from '../utils/debugLogger.js'; */ export class McpClientManager { private clients: Map = new Map(); + // Track all configured servers (including disabled ones) for UI display + private allServerConfigs: Map = new Map(); private readonly clientVersion: string; private readonly toolRegistry: ToolRegistry; private readonly cliConfig: Config; @@ -97,24 +99,44 @@ export class McpClientManager { await this.cliConfig.refreshMcpContext(); } - private isAllowedMcpServer(name: string) { + /** + * Check if server is blocked by admin settings (allowlist/excludelist). + * Returns true if blocked, false if allowed. + */ + private isBlockedBySettings(name: string): boolean { const allowedNames = this.cliConfig.getAllowedMcpServers(); if ( allowedNames && allowedNames.length > 0 && - allowedNames.indexOf(name) === -1 + !allowedNames.includes(name) ) { - return false; + return true; } const blockedNames = this.cliConfig.getBlockedMcpServers(); if ( blockedNames && blockedNames.length > 0 && - blockedNames.indexOf(name) !== -1 + blockedNames.includes(name) ) { - return false; + return true; } - return true; + return false; + } + + /** + * Check if server is disabled by user (session or file-based). + */ + private async isDisabledByUser(name: string): Promise { + const callbacks = this.cliConfig.getMcpEnablementCallbacks(); + if (callbacks) { + if (callbacks.isSessionDisabled(name)) { + return true; + } + if (!(await callbacks.isFileEnabled(name))) { + return true; + } + } + return false; } private async disconnectClient(name: string, skipRefresh = false) { @@ -138,11 +160,15 @@ export class McpClientManager { } } - maybeDiscoverMcpServer( + async maybeDiscoverMcpServer( name: string, config: MCPServerConfig, - ): Promise | void { - if (!this.isAllowedMcpServer(name)) { + ): Promise { + // Always track server config for UI display + this.allServerConfigs.set(name, config); + + // Check if blocked by admin settings (allowlist/excludelist) + if (this.isBlockedBySettings(name)) { if (!this.blockedMcpServers.find((s) => s.name === name)) { this.blockedMcpServers?.push({ name, @@ -151,6 +177,14 @@ export class McpClientManager { } return; } + // User-disabled servers: disconnect if running, don't start + if (await this.isDisabledByUser(name)) { + const existing = this.clients.get(name); + if (existing) { + await this.disconnectClient(name); + } + return; + } if (!this.cliConfig.isTrustedFolder()) { return; } @@ -273,6 +307,11 @@ export class McpClientManager { this.cliConfig.getMcpServerCommand(), ); + // Set state synchronously before any await yields control + if (!this.discoveryPromise) { + this.discoveryState = MCPDiscoveryState.IN_PROGRESS; + } + this.eventEmitter?.emit('mcp-client-update', this.clients); await Promise.all( Object.entries(servers).map(([name, config]) => @@ -283,23 +322,21 @@ export class McpClientManager { } /** - * Restarts all active MCP Clients. + * Restarts all MCP servers (including newly enabled ones). */ async restart(): Promise { await Promise.all( - Array.from(this.clients.keys()).map(async (name) => { - const client = this.clients.get(name); - if (!client) { - return; - } - try { - await this.maybeDiscoverMcpServer(name, client.getServerConfig()); - } catch (error) { - debugLogger.error( - `Error restarting client '${name}': ${getErrorMessage(error)}`, - ); - } - }), + Array.from(this.allServerConfigs.entries()).map( + async ([name, config]) => { + try { + await this.maybeDiscoverMcpServer(name, config); + } catch (error) { + debugLogger.error( + `Error restarting client '${name}': ${getErrorMessage(error)}`, + ); + } + }, + ), ); await this.cliConfig.refreshMcpContext(); } @@ -308,11 +345,11 @@ export class McpClientManager { * Restart a single MCP server by name. */ async restartServer(name: string) { - const client = this.clients.get(name); - if (!client) { + const config = this.allServerConfigs.get(name); + if (!config) { throw new Error(`No MCP server registered with the name "${name}"`); } - await this.maybeDiscoverMcpServer(name, client.getServerConfig()); + await this.maybeDiscoverMcpServer(name, config); await this.cliConfig.refreshMcpContext(); } @@ -344,12 +381,12 @@ export class McpClientManager { } /** - * All of the MCP server configurations currently loaded. + * All of the MCP server configurations (including disabled ones). */ getMcpServers(): Record { const mcpServers: Record = {}; - for (const [name, client] of this.clients.entries()) { - mcpServers[name] = client.getServerConfig(); + for (const [name, config] of this.allServerConfigs.entries()) { + mcpServers[name] = config; } return mcpServers; }