mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 22:55:13 +00:00
Co-authored-by: Allen Hutchison <adh@google.com>
This commit is contained in:
@@ -1038,6 +1038,29 @@ gemini mcp remove my-server
|
|||||||
This will find and delete the "my-server" entry from the `mcpServers` object in
|
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`).
|
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
|
## Instructions
|
||||||
|
|
||||||
Gemini CLI supports
|
Gemini CLI supports
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ describe('mcp command', () => {
|
|||||||
|
|
||||||
(mcpCommand.builder as (y: Argv) => Argv)(mockYargs as unknown as Argv);
|
(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
|
// Verify that the specific subcommands are registered
|
||||||
const commandCalls = mockYargs.command.mock.calls;
|
const commandCalls = mockYargs.command.mock.calls;
|
||||||
@@ -70,6 +70,8 @@ describe('mcp command', () => {
|
|||||||
expect(commandNames).toContain('add <name> <commandOrUrl> [args...]');
|
expect(commandNames).toContain('add <name> <commandOrUrl> [args...]');
|
||||||
expect(commandNames).toContain('remove <name>');
|
expect(commandNames).toContain('remove <name>');
|
||||||
expect(commandNames).toContain('list');
|
expect(commandNames).toContain('list');
|
||||||
|
expect(commandNames).toContain('enable <name>');
|
||||||
|
expect(commandNames).toContain('disable <name>');
|
||||||
|
|
||||||
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
|
expect(mockYargs.demandCommand).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type { CommandModule, Argv } from 'yargs';
|
|||||||
import { addCommand } from './mcp/add.js';
|
import { addCommand } from './mcp/add.js';
|
||||||
import { removeCommand } from './mcp/remove.js';
|
import { removeCommand } from './mcp/remove.js';
|
||||||
import { listCommand } from './mcp/list.js';
|
import { listCommand } from './mcp/list.js';
|
||||||
|
import { enableCommand, disableCommand } from './mcp/enableDisable.js';
|
||||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||||
import { defer } from '../deferred.js';
|
import { defer } from '../deferred.js';
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ export const mcpCommand: CommandModule = {
|
|||||||
.command(defer(addCommand, 'mcp'))
|
.command(defer(addCommand, 'mcp'))
|
||||||
.command(defer(removeCommand, 'mcp'))
|
.command(defer(removeCommand, 'mcp'))
|
||||||
.command(defer(listCommand, 'mcp'))
|
.command(defer(listCommand, 'mcp'))
|
||||||
|
.command(defer(enableCommand, 'mcp'))
|
||||||
|
.command(defer(disableCommand, 'mcp'))
|
||||||
.demandCommand(1, 'You need at least one command before continuing.')
|
.demandCommand(1, 'You need at least one command before continuing.')
|
||||||
.version(false),
|
.version(false),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
|
|||||||
169
packages/cli/src/commands/mcp/enableDisable.ts
Normal file
169
packages/cli/src/commands/mcp/enableDisable.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -24,7 +24,7 @@ const COLOR_YELLOW = '\u001b[33m';
|
|||||||
const COLOR_RED = '\u001b[31m';
|
const COLOR_RED = '\u001b[31m';
|
||||||
const RESET_COLOR = '\u001b[0m';
|
const RESET_COLOR = '\u001b[0m';
|
||||||
|
|
||||||
async function getMcpServersFromConfig(): Promise<
|
export async function getMcpServersFromConfig(): Promise<
|
||||||
Record<string, MCPServerConfig>
|
Record<string, MCPServerConfig>
|
||||||
> {
|
> {
|
||||||
const settings = loadSettings();
|
const settings = loadSettings();
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { RESUME_LATEST } from '../utils/sessionUtils.js';
|
|||||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
import { createPolicyEngineConfig } from './policy.js';
|
import { createPolicyEngineConfig } from './policy.js';
|
||||||
import { ExtensionManager } from './extension-manager.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 type { ExtensionEvents } from '@google/gemini-cli-core/src/utils/extensionLoader.js';
|
||||||
import { requestConsentNonInteractive } from './extensions/consent.js';
|
import { requestConsentNonInteractive } from './extensions/consent.js';
|
||||||
import { promptForSetting } from './extensions/extensionSettings.js';
|
import { promptForSetting } from './extensions/extensionSettings.js';
|
||||||
@@ -665,6 +666,12 @@ export async function loadCliConfig(
|
|||||||
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
|
const extensionsEnabled = settings.admin?.extensions?.enabled ?? true;
|
||||||
const adminSkillsEnabled = settings.admin?.skills?.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({
|
return new Config({
|
||||||
sessionId,
|
sessionId,
|
||||||
clientVersion: await getVersion(),
|
clientVersion: await getVersion(),
|
||||||
@@ -686,6 +693,7 @@ export async function loadCliConfig(
|
|||||||
toolCallCommand: settings.tools?.callCommand,
|
toolCallCommand: settings.tools?.callCommand,
|
||||||
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
|
mcpServerCommand: mcpEnabled ? settings.mcp?.serverCommand : undefined,
|
||||||
mcpServers: mcpEnabled ? settings.mcpServers : {},
|
mcpServers: mcpEnabled ? settings.mcpServers : {},
|
||||||
|
mcpEnablementCallbacks,
|
||||||
mcpEnabled,
|
mcpEnabled,
|
||||||
extensionsEnabled,
|
extensionsEnabled,
|
||||||
agents: settings.agents,
|
agents: settings.agents,
|
||||||
|
|||||||
17
packages/cli/src/config/mcp/index.ts
Normal file
17
packages/cli/src/config/mcp/index.ts
Normal 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';
|
||||||
188
packages/cli/src/config/mcp/mcpServerEnablement.test.ts
Normal file
188
packages/cli/src/config/mcp/mcpServerEnablement.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
357
packages/cli/src/config/mcp/mcpServerEnablement.ts
Normal file
357
packages/cli/src/config/mcp/mcpServerEnablement.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,12 @@ import {
|
|||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||||
import { MessageType, type HistoryItemMcpStatus } from '../types.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 = {
|
const authCommand: SlashCommand = {
|
||||||
name: 'auth',
|
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 = {
|
const mcpStatusItem: HistoryItemMcpStatus = {
|
||||||
type: MessageType.MCP_STATUS,
|
type: MessageType.MCP_STATUS,
|
||||||
servers: mcpServers,
|
servers: mcpServers,
|
||||||
@@ -263,6 +277,7 @@ const listAction = async (
|
|||||||
description: resource.description,
|
description: resource.description,
|
||||||
})),
|
})),
|
||||||
authStatus,
|
authStatus,
|
||||||
|
enablementState,
|
||||||
blockedServers: blockedMcpServers,
|
blockedServers: blockedMcpServers,
|
||||||
discoveryInProgress,
|
discoveryInProgress,
|
||||||
connectingServers,
|
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 = {
|
export const mcpCommand: SlashCommand = {
|
||||||
name: 'mcp',
|
name: 'mcp',
|
||||||
description: 'Manage configured Model Context Protocol (MCP) servers',
|
description: 'Manage configured Model Context Protocol (MCP) servers',
|
||||||
@@ -357,6 +522,8 @@ export const mcpCommand: SlashCommand = {
|
|||||||
schemaCommand,
|
schemaCommand,
|
||||||
authCommand,
|
authCommand,
|
||||||
refreshCommand,
|
refreshCommand,
|
||||||
|
enableCommand,
|
||||||
|
disableCommand,
|
||||||
],
|
],
|
||||||
action: async (context: CommandContext) => listAction(context),
|
action: async (context: CommandContext) => listAction(context),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ describe('McpStatus', () => {
|
|||||||
blockedServers: [],
|
blockedServers: [],
|
||||||
serverStatus: () => MCPServerStatus.CONNECTED,
|
serverStatus: () => MCPServerStatus.CONNECTED,
|
||||||
authStatus: {},
|
authStatus: {},
|
||||||
|
enablementState: {
|
||||||
|
'server-1': {
|
||||||
|
enabled: true,
|
||||||
|
isSessionDisabled: false,
|
||||||
|
isPersistentDisabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
discoveryInProgress: false,
|
discoveryInProgress: false,
|
||||||
connectingServers: [],
|
connectingServers: [],
|
||||||
showDescriptions: true,
|
showDescriptions: true,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface McpStatusProps {
|
|||||||
blockedServers: Array<{ name: string; extensionName: string }>;
|
blockedServers: Array<{ name: string; extensionName: string }>;
|
||||||
serverStatus: (serverName: string) => MCPServerStatus;
|
serverStatus: (serverName: string) => MCPServerStatus;
|
||||||
authStatus: HistoryItemMcpStatus['authStatus'];
|
authStatus: HistoryItemMcpStatus['authStatus'];
|
||||||
|
enablementState: HistoryItemMcpStatus['enablementState'];
|
||||||
discoveryInProgress: boolean;
|
discoveryInProgress: boolean;
|
||||||
connectingServers: string[];
|
connectingServers: string[];
|
||||||
showDescriptions: boolean;
|
showDescriptions: boolean;
|
||||||
@@ -39,6 +40,7 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
|||||||
blockedServers,
|
blockedServers,
|
||||||
serverStatus,
|
serverStatus,
|
||||||
authStatus,
|
authStatus,
|
||||||
|
enablementState,
|
||||||
discoveryInProgress,
|
discoveryInProgress,
|
||||||
connectingServers,
|
connectingServers,
|
||||||
showDescriptions,
|
showDescriptions,
|
||||||
@@ -104,23 +106,35 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
|||||||
let statusText = '';
|
let statusText = '';
|
||||||
let statusColor = theme.text.primary;
|
let statusColor = theme.text.primary;
|
||||||
|
|
||||||
switch (status) {
|
// Check enablement state
|
||||||
case MCPServerStatus.CONNECTED:
|
const serverEnablement = enablementState[serverName];
|
||||||
statusIndicator = '🟢';
|
const isDisabled = serverEnablement && !serverEnablement.enabled;
|
||||||
statusText = 'Ready';
|
|
||||||
statusColor = theme.status.success;
|
if (isDisabled) {
|
||||||
break;
|
statusIndicator = '⏸️';
|
||||||
case MCPServerStatus.CONNECTING:
|
statusText = serverEnablement.isSessionDisabled
|
||||||
statusIndicator = '🔄';
|
? 'Disabled (session)'
|
||||||
statusText = 'Starting... (first startup may take longer)';
|
: 'Disabled';
|
||||||
statusColor = theme.status.warning;
|
statusColor = theme.text.secondary;
|
||||||
break;
|
} else {
|
||||||
case MCPServerStatus.DISCONNECTED:
|
switch (status) {
|
||||||
default:
|
case MCPServerStatus.CONNECTED:
|
||||||
statusIndicator = '🔴';
|
statusIndicator = '🟢';
|
||||||
statusText = 'Disconnected';
|
statusText = 'Ready';
|
||||||
statusColor = theme.status.error;
|
statusColor = theme.status.success;
|
||||||
break;
|
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;
|
let serverDisplayName = serverName;
|
||||||
|
|||||||
@@ -270,6 +270,14 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
|
|||||||
string,
|
string,
|
||||||
'authenticated' | 'expired' | 'unauthenticated' | 'not-configured'
|
'authenticated' | 'expired' | 'unauthenticated' | 'not-configured'
|
||||||
>;
|
>;
|
||||||
|
enablementState: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
enabled: boolean;
|
||||||
|
isSessionDisabled: boolean;
|
||||||
|
isPersistentDisabled: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
blockedServers: Array<{ name: string; extensionName: string }>;
|
blockedServers: Array<{ name: string; extensionName: string }>;
|
||||||
discoveryInProgress: boolean;
|
discoveryInProgress: boolean;
|
||||||
connectingServers: string[];
|
connectingServers: string[];
|
||||||
|
|||||||
@@ -278,6 +278,18 @@ export interface SandboxConfig {
|
|||||||
image: string;
|
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 {
|
export interface ConfigParameters {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
clientVersion?: string;
|
clientVersion?: string;
|
||||||
@@ -294,6 +306,7 @@ export interface ConfigParameters {
|
|||||||
toolCallCommand?: string;
|
toolCallCommand?: string;
|
||||||
mcpServerCommand?: string;
|
mcpServerCommand?: string;
|
||||||
mcpServers?: Record<string, MCPServerConfig>;
|
mcpServers?: Record<string, MCPServerConfig>;
|
||||||
|
mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||||
userMemory?: string;
|
userMemory?: string;
|
||||||
geminiMdFileCount?: number;
|
geminiMdFileCount?: number;
|
||||||
geminiMdFilePaths?: string[];
|
geminiMdFilePaths?: string[];
|
||||||
@@ -426,6 +439,7 @@ export class Config {
|
|||||||
private readonly mcpEnabled: boolean;
|
private readonly mcpEnabled: boolean;
|
||||||
private readonly extensionsEnabled: boolean;
|
private readonly extensionsEnabled: boolean;
|
||||||
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
private mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||||
|
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
|
||||||
private userMemory: string;
|
private userMemory: string;
|
||||||
private geminiMdFileCount: number;
|
private geminiMdFileCount: number;
|
||||||
private geminiMdFilePaths: string[];
|
private geminiMdFilePaths: string[];
|
||||||
@@ -564,6 +578,7 @@ export class Config {
|
|||||||
this.toolCallCommand = params.toolCallCommand;
|
this.toolCallCommand = params.toolCallCommand;
|
||||||
this.mcpServerCommand = params.mcpServerCommand;
|
this.mcpServerCommand = params.mcpServerCommand;
|
||||||
this.mcpServers = params.mcpServers;
|
this.mcpServers = params.mcpServers;
|
||||||
|
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
|
||||||
this.mcpEnabled = params.mcpEnabled ?? true;
|
this.mcpEnabled = params.mcpEnabled ?? true;
|
||||||
this.extensionsEnabled = params.extensionsEnabled ?? true;
|
this.extensionsEnabled = params.extensionsEnabled ?? true;
|
||||||
this.allowedMcpServers = params.allowedMcpServers ?? [];
|
this.allowedMcpServers = params.allowedMcpServers ?? [];
|
||||||
@@ -1235,6 +1250,10 @@ export class Config {
|
|||||||
return this.mcpEnabled;
|
return this.mcpEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getMcpEnablementCallbacks(): McpEnablementCallbacks | undefined {
|
||||||
|
return this.mcpEnablementCallbacks;
|
||||||
|
}
|
||||||
|
|
||||||
getExtensionsEnabled(): boolean {
|
getExtensionsEnabled(): boolean {
|
||||||
return this.extensionsEnabled;
|
return this.extensionsEnabled;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ describe('McpClientManager', () => {
|
|||||||
getAllowedMcpServers: vi.fn().mockReturnValue([]),
|
getAllowedMcpServers: vi.fn().mockReturnValue([]),
|
||||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||||
getMcpServerCommand: vi.fn().mockReturnValue(''),
|
getMcpServerCommand: vi.fn().mockReturnValue(''),
|
||||||
|
getMcpEnablementCallbacks: vi.fn().mockReturnValue(undefined),
|
||||||
getGeminiClient: vi.fn().mockReturnValue({
|
getGeminiClient: vi.fn().mockReturnValue({
|
||||||
isInitialized: vi.fn(),
|
isInitialized: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import { debugLogger } from '../utils/debugLogger.js';
|
|||||||
*/
|
*/
|
||||||
export class McpClientManager {
|
export class McpClientManager {
|
||||||
private clients: Map<string, McpClient> = new Map();
|
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 clientVersion: string;
|
||||||
private readonly toolRegistry: ToolRegistry;
|
private readonly toolRegistry: ToolRegistry;
|
||||||
private readonly cliConfig: Config;
|
private readonly cliConfig: Config;
|
||||||
@@ -97,24 +99,44 @@ export class McpClientManager {
|
|||||||
await this.cliConfig.refreshMcpContext();
|
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();
|
const allowedNames = this.cliConfig.getAllowedMcpServers();
|
||||||
if (
|
if (
|
||||||
allowedNames &&
|
allowedNames &&
|
||||||
allowedNames.length > 0 &&
|
allowedNames.length > 0 &&
|
||||||
allowedNames.indexOf(name) === -1
|
!allowedNames.includes(name)
|
||||||
) {
|
) {
|
||||||
return false;
|
return true;
|
||||||
}
|
}
|
||||||
const blockedNames = this.cliConfig.getBlockedMcpServers();
|
const blockedNames = this.cliConfig.getBlockedMcpServers();
|
||||||
if (
|
if (
|
||||||
blockedNames &&
|
blockedNames &&
|
||||||
blockedNames.length > 0 &&
|
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) {
|
private async disconnectClient(name: string, skipRefresh = false) {
|
||||||
@@ -138,11 +160,15 @@ export class McpClientManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeDiscoverMcpServer(
|
async maybeDiscoverMcpServer(
|
||||||
name: string,
|
name: string,
|
||||||
config: MCPServerConfig,
|
config: MCPServerConfig,
|
||||||
): Promise<void> | void {
|
): Promise<void> {
|
||||||
if (!this.isAllowedMcpServer(name)) {
|
// 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)) {
|
if (!this.blockedMcpServers.find((s) => s.name === name)) {
|
||||||
this.blockedMcpServers?.push({
|
this.blockedMcpServers?.push({
|
||||||
name,
|
name,
|
||||||
@@ -151,6 +177,14 @@ export class McpClientManager {
|
|||||||
}
|
}
|
||||||
return;
|
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()) {
|
if (!this.cliConfig.isTrustedFolder()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -273,6 +307,11 @@ export class McpClientManager {
|
|||||||
this.cliConfig.getMcpServerCommand(),
|
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);
|
this.eventEmitter?.emit('mcp-client-update', this.clients);
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.entries(servers).map(([name, config]) =>
|
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> {
|
async restart(): Promise<void> {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from(this.clients.keys()).map(async (name) => {
|
Array.from(this.allServerConfigs.entries()).map(
|
||||||
const client = this.clients.get(name);
|
async ([name, config]) => {
|
||||||
if (!client) {
|
try {
|
||||||
return;
|
await this.maybeDiscoverMcpServer(name, config);
|
||||||
}
|
} catch (error) {
|
||||||
try {
|
debugLogger.error(
|
||||||
await this.maybeDiscoverMcpServer(name, client.getServerConfig());
|
`Error restarting client '${name}': ${getErrorMessage(error)}`,
|
||||||
} catch (error) {
|
);
|
||||||
debugLogger.error(
|
}
|
||||||
`Error restarting client '${name}': ${getErrorMessage(error)}`,
|
},
|
||||||
);
|
),
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
await this.cliConfig.refreshMcpContext();
|
await this.cliConfig.refreshMcpContext();
|
||||||
}
|
}
|
||||||
@@ -308,11 +345,11 @@ export class McpClientManager {
|
|||||||
* Restart a single MCP server by name.
|
* Restart a single MCP server by name.
|
||||||
*/
|
*/
|
||||||
async restartServer(name: string) {
|
async restartServer(name: string) {
|
||||||
const client = this.clients.get(name);
|
const config = this.allServerConfigs.get(name);
|
||||||
if (!client) {
|
if (!config) {
|
||||||
throw new Error(`No MCP server registered with the name "${name}"`);
|
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();
|
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> {
|
getMcpServers(): Record<string, MCPServerConfig> {
|
||||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||||
for (const [name, client] of this.clients.entries()) {
|
for (const [name, config] of this.allServerConfigs.entries()) {
|
||||||
mcpServers[name] = client.getServerConfig();
|
mcpServers[name] = config;
|
||||||
}
|
}
|
||||||
return mcpServers;
|
return mcpServers;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user