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
+8
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,
+17
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';
@@ -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();
});
});
@@ -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));
}
}