Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/tools/mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,10 @@ provide tools. Enablement state is stored in
The same commands are available as slash commands during an active session:
`/mcp enable <name>` and `/mcp disable <name>`.

If `mcp-server-enablement.json` exists but cannot be read or parsed, every MCP
server is treated as disabled until the file is repaired or deleted, and
`/mcp enable|disable` refuse to write rather than overwrite it.

## Instructions

Gemini CLI supports
Expand Down
16 changes: 13 additions & 3 deletions packages/cli/src/commands/mcp/enableDisable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { debugLogger, getErrorMessage } from '@google/gemini-cli-core';
import {
McpServerEnablementManager,
canLoadServer,
Expand Down Expand Up @@ -60,7 +60,12 @@ async function handleEnable(args: Args): Promise<void> {
manager.clearSessionDisable(name);
debugLogger.log(`${GREEN}✓${RESET} Session disable cleared for '${name}'.`);
} else {
await manager.enable(name);
try {
await manager.enable(name);
} catch (error) {
debugLogger.log(`${RED}Error:${RESET} ${getErrorMessage(error)}`);
return;
}
debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' enabled.`);
}

Expand Down Expand Up @@ -91,7 +96,12 @@ async function handleDisable(args: Args): Promise<void> {
`${GREEN}✓${RESET} MCP server '${name}' disabled for this session.`,
);
} else {
await manager.disable(name);
try {
await manager.disable(name);
} catch (error) {
debugLogger.log(`${RED}Error:${RESET} ${getErrorMessage(error)}`);
return;
}
debugLogger.log(`${GREEN}✓${RESET} MCP server '${name}' disabled.`);
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/config/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

export {
McpServerEnablementManager,
McpServerEnablementConfigError,
canLoadServer,
normalizeServerId,
isInSettingsList,
Expand Down
153 changes: 153 additions & 0 deletions packages/cli/src/config/mcp/mcpServerEnablement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import fs from 'node:fs/promises';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('@google/gemini-cli-core', async (importOriginal) => {
Expand All @@ -20,14 +21,23 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});

import { coreEvents } from '@google/gemini-cli-core';
import {
McpServerEnablementManager,
McpServerEnablementConfigError,
canLoadServer,
normalizeServerId,
isInSettingsList,
type EnablementCallbacks,
} from './mcpServerEnablement.js';

// Derived the same way the manager derives it. A POSIX literal would not match
// the path.join() result on win32, where the whole suite runs in CI.
const CONFIG_PATH = path.join(
'/virtual-home/.gemini',
'mcp-server-enablement.json',
);

let inMemoryFs: Record<string, string> = {};

function createMockEnablement(
Expand Down Expand Up @@ -121,6 +131,149 @@ describe('McpServerEnablementManager', () => {
});
});

describe('McpServerEnablementManager with an unreadable config file', () => {
let manager: McpServerEnablementManager;

beforeEach(() => {
inMemoryFs = {};
setupFsMocks();
McpServerEnablementManager.resetInstance();
manager = McpServerEnablementManager.getInstance();
});

afterEach(() => {
vi.restoreAllMocks();
McpServerEnablementManager.resetInstance();
});

it.each([
['malformed JSON', '{"playwright": {"enabled": false'],
['a JSON array', '["playwright"]'],
['a JSON scalar', '"playwright"'],
['entries that are not enablement states', '{"playwright": "off"}'],
[
'an entry with a non-boolean enabled',
'{"playwright": {"enabled": "no"}}',
],
])(
'fails closed on %s rather than reporting every server enabled',
async (_label, content) => {
inMemoryFs[CONFIG_PATH] = content;

expect(await manager.isFileEnabled('playwright')).toBe(false);
expect(await manager.isFileEnabled('never-configured')).toBe(false);
},
);

it('reports servers as persistently disabled in the display state', async () => {
inMemoryFs[CONFIG_PATH] = '{ truncated';

expect(await manager.getDisplayState('playwright')).toEqual({
enabled: false,
isSessionDisabled: false,
isPersistentDisabled: true,
});
});

it('refuses to disable a server and leaves the file untouched', async () => {
const original = '{"playwright": {"enabled": false}, "github": ';
inMemoryFs[CONFIG_PATH] = original;

await expect(manager.disable('other')).rejects.toBeInstanceOf(
McpServerEnablementConfigError,
);
expect(inMemoryFs[CONFIG_PATH]).toBe(original);
});

it('refuses to enable a server and leaves the file untouched', async () => {
const original = '{"playwright": {"enabled": false}, "github": ';
inMemoryFs[CONFIG_PATH] = original;

await expect(manager.enable('playwright')).rejects.toBeInstanceOf(
McpServerEnablementConfigError,
);
expect(inMemoryFs[CONFIG_PATH]).toBe(original);
});

it('names the config file so the user can repair it', async () => {
inMemoryFs[CONFIG_PATH] = '{ truncated';

await expect(manager.disable('playwright')).rejects.toThrow(CONFIG_PATH);
});

it('auto-enable re-enables nothing and does not throw', async () => {
inMemoryFs[CONFIG_PATH] = '{ truncated';

await expect(manager.autoEnableServers(['playwright'])).resolves.toEqual(
[],
);
expect(inMemoryFs[CONFIG_PATH]).toBe('{ truncated');
});

it('recovers once the file is repaired', async () => {
inMemoryFs[CONFIG_PATH] = '{ truncated';
expect(await manager.isFileEnabled('playwright')).toBe(false);

inMemoryFs[CONFIG_PATH] = '{"playwright": {"enabled": false}}';
expect(await manager.isFileEnabled('playwright')).toBe(false);
expect(await manager.isFileEnabled('github')).toBe(true);

await manager.enable('playwright');
expect(await manager.isFileEnabled('playwright')).toBe(true);
});

it('still treats a missing file as an empty config', async () => {
expect(await manager.isFileEnabled('playwright')).toBe(true);
await expect(manager.enable('playwright')).resolves.toBeUndefined();
});

it('fails closed when the file exists but cannot be read at all', async () => {
// Every other case here supplies readable bytes and fails at JSON.parse or
// the shape check, so without this the non-ENOENT arm of the read catch is
// never driven.
vi.spyOn(fs, 'readFile').mockImplementation(async () => {
const error = new Error('EACCES: permission denied');
(error as NodeJS.ErrnoException).code = 'EACCES';
throw error;
});

expect(await manager.isFileEnabled('playwright')).toBe(false);
await expect(manager.disable('playwright')).rejects.toBeInstanceOf(
McpServerEnablementConfigError,
);
});

it('reports an unreadable config once per stretch, not once per read', async () => {
// isFileEnabled runs for every server on every connection attempt, so an
// unconditional emit would bury the message in copies of itself.
const emitFeedback = vi
.spyOn(coreEvents, 'emitFeedback')
.mockImplementation(() => {});
inMemoryFs[CONFIG_PATH] = '{ truncated';

await manager.isFileEnabled('playwright');
await manager.isFileEnabled('github');
await manager.isFileEnabled('other');
expect(emitFeedback).toHaveBeenCalledTimes(1);
expect(emitFeedback.mock.calls[0][1]).toContain(CONFIG_PATH);

// A successful read rearms it, so a second episode is reported again.
inMemoryFs[CONFIG_PATH] = '{"playwright": {"enabled": false}}';
await manager.isFileEnabled('playwright');
inMemoryFs[CONFIG_PATH] = '{ truncated again';
await manager.isFileEnabled('playwright');
expect(emitFeedback).toHaveBeenCalledTimes(2);
});

it('accepts entries carrying unknown extra fields', async () => {
inMemoryFs[CONFIG_PATH] =
'{"playwright": {"enabled": false, "disabledAt": "2026-01-01"}}';

expect(await manager.isFileEnabled('playwright')).toBe(false);
expect(await manager.isFileEnabled('github')).toBe(true);
});
});

describe('canLoadServer', () => {
it('blocks when admin has disabled MCP', async () => {
const result = await canLoadServer('s', { adminMcpEnabled: false });
Expand Down
Loading
Loading