feat(mcp): add enable/disable commands for MCP servers (#11057) (#16299)

Co-authored-by: Allen Hutchison <adh@google.com>
This commit is contained in:
Jasmeet Bhatia
2026-01-22 15:38:06 -08:00
committed by GitHub
parent 35feea8868
commit a060e6149a
16 changed files with 1068 additions and 48 deletions

View File

@@ -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 <name> [--session]
gemini mcp disable <name> [--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 <name>` and `/mcp disable <name>`.
## Instructions
Gemini CLI supports

View File

@@ -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 <name> <commandOrUrl> [args...]');
expect(commandNames).toContain('remove <name>');
expect(commandNames).toContain('list');
expect(commandNames).toContain('enable <name>');
expect(commandNames).toContain('disable <name>');
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
1,

View File

@@ -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: () => {

View File

@@ -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<void> {
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<void> {
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<object, Args> = {
command: 'enable <name>',
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<object, Args> = {
command: 'disable <name>',
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();
},
};

View File

@@ -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<string, MCPServerConfig>
> {
const settings = loadSettings();

View File

@@ -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,

View File

@@ -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';

View File

@@ -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<typeof import('@google/gemini-cli-core')>();
return {
...actual,
Storage: {
...actual.Storage,
getGlobalGeminiDir: () => '/virtual-home/.gemini',
},
};
});
import {
McpServerEnablementManager,
canLoadServer,
normalizeServerId,
isInSettingsList,
type EnablementCallbacks,
} from './mcpServerEnablement.js';
let inMemoryFs: Record<string, string> = {};
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();
});
});

View File

@@ -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<boolean>;
}
/**
* 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<ServerLoadResult> {
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<string>();
/**
* 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<boolean> {
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<boolean> {
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<void> {
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<void> {
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<McpServerDisplayState> {
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<Record<string, McpServerDisplayState>> {
const result: Record<string, McpServerDisplayState> = {};
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<McpServerEnablementConfig> {
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<void> {
await fs.mkdir(this.configDir, { recursive: true });
await fs.writeFile(this.configFilePath, JSON.stringify(config, null, 2));
}
}

View File

@@ -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<MessageActionReturn> {
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} <server-name> [--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<string[]> {
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),
};

View File

@@ -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,

View File

@@ -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<McpStatusProps> = ({
blockedServers,
serverStatus,
authStatus,
enablementState,
discoveryInProgress,
connectingServers,
showDescriptions,
@@ -104,23 +106,35 @@ export const McpStatus: React.FC<McpStatusProps> = ({
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;

View File

@@ -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[];

View File

@@ -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<boolean>;
}
export interface ConfigParameters {
sessionId: string;
clientVersion?: string;
@@ -294,6 +306,7 @@ export interface ConfigParameters {
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
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<string, MCPServerConfig> | 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;
}

View File

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

View File

@@ -27,6 +27,8 @@ import { debugLogger } from '../utils/debugLogger.js';
*/
export class McpClientManager {
private clients: Map<string, McpClient> = new Map();
// Track all configured servers (including disabled ones) for UI display
private allServerConfigs: Map<string, MCPServerConfig> = 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<boolean> {
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> | void {
if (!this.isAllowedMcpServer(name)) {
): Promise<void> {
// 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<void> {
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<string, MCPServerConfig> {
const mcpServers: Record<string, MCPServerConfig> = {};
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;
}