/** * @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), }; } /** * Auto-enable any disabled MCP servers by name. * Returns server names that were actually re-enabled. */ async autoEnableServers(serverNames: string[]): Promise { const enabledServers: string[] = []; for (const serverName of serverNames) { const normalizedName = normalizeServerId(serverName); const state = await this.getDisplayState(normalizedName); let wasDisabled = false; if (state.isPersistentDisabled) { await this.enable(normalizedName); wasDisabled = true; } if (state.isSessionDisabled) { this.clearSessionDisable(normalizedName); wasDisabled = true; } if (wasDisabled) { enabledServers.push(serverName); } } return enabledServers; } /** * Read config from file asynchronously. */ private async readConfig(): Promise { try { const content = await fs.readFile(this.configFilePath, 'utf-8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion 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)); } }