mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
fix(core): reduce intrusive MCP errors and deduplicate diagnostics (#20232)
This commit is contained in:
@@ -4,7 +4,15 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
|
||||
import {
|
||||
vi,
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import { listMcpServers } from './list.js';
|
||||
import { loadSettings, mergeSettings } from '../../config/settings.js';
|
||||
import { createTransport, debugLogger } from '@google/gemini-cli-core';
|
||||
@@ -106,6 +114,10 @@ describe('mcp list command', () => {
|
||||
mockedGetUserExtensionsDir.mockReturnValue('/mocked/extensions/dir');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should display message when no servers configured', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
@@ -133,6 +145,7 @@ describe('mcp list command', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
@@ -199,6 +212,7 @@ describe('mcp list command', () => {
|
||||
'config-server': { command: '/config/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: true,
|
||||
});
|
||||
|
||||
mockExtensionManager.loadExtensions.mockReturnValue([
|
||||
@@ -266,4 +280,28 @@ describe('mcp list command', () => {
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should show stdio servers as disconnected in untrusted folders', async () => {
|
||||
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: {
|
||||
...defaultMergedSettings,
|
||||
mcpServers: {
|
||||
'test-server': { command: '/test/server' },
|
||||
},
|
||||
},
|
||||
isTrusted: false,
|
||||
});
|
||||
|
||||
// createTransport will throw in core if not trusted
|
||||
mockedCreateTransport.mockRejectedValue(new Error('Folder not trusted'));
|
||||
|
||||
await listMcpServers();
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'test-server: /test/server (stdio) - Disconnected',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,11 +20,7 @@ import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
import { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||
import { exitCli } from '../utils.js';
|
||||
|
||||
const COLOR_GREEN = '\u001b[32m';
|
||||
const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export async function getMcpServersFromConfig(
|
||||
settings?: MergedSettings,
|
||||
@@ -66,27 +62,56 @@ async function testMCPConnection(
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
): Promise<MCPServerStatus> {
|
||||
const settings = loadSettings();
|
||||
|
||||
// SECURITY: Only test connection if workspace is trusted or if it's a remote server.
|
||||
// stdio servers execute local commands and must never run in untrusted workspaces.
|
||||
const isStdio = !!config.command;
|
||||
if (isStdio && !settings.isTrusted) {
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
name: 'mcp-test-client',
|
||||
version: '0.0.1',
|
||||
});
|
||||
|
||||
const settings = loadSettings();
|
||||
const sanitizationConfig = {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
|
||||
const mcpContext = {
|
||||
sanitizationConfig: {
|
||||
enableEnvironmentVariableRedaction: true,
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: settings.merged.advanced.excludedEnvVars,
|
||||
},
|
||||
emitMcpDiagnostic: (
|
||||
severity: 'info' | 'warning' | 'error',
|
||||
message: string,
|
||||
error?: unknown,
|
||||
serverName?: string,
|
||||
) => {
|
||||
// In non-interactive list, we log everything through debugLogger for consistency
|
||||
if (severity === 'error') {
|
||||
debugLogger.error(
|
||||
chalk.red(`Error${serverName ? ` (${serverName})` : ''}: ${message}`),
|
||||
error,
|
||||
);
|
||||
} else if (severity === 'warning') {
|
||||
debugLogger.warn(
|
||||
chalk.yellow(
|
||||
`Warning${serverName ? ` (${serverName})` : ''}: ${message}`,
|
||||
),
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
debugLogger.log(message, error);
|
||||
}
|
||||
},
|
||||
isTrustedFolder: () => settings.isTrusted,
|
||||
};
|
||||
|
||||
let transport;
|
||||
try {
|
||||
// Use the same transport creation logic as core
|
||||
transport = await createTransport(
|
||||
serverName,
|
||||
config,
|
||||
false,
|
||||
sanitizationConfig,
|
||||
);
|
||||
transport = await createTransport(serverName, config, false, mcpContext);
|
||||
} catch (_error) {
|
||||
await client.close();
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
@@ -125,7 +150,7 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
|
||||
blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n');
|
||||
debugLogger.log(chalk.yellow(message + '\n'));
|
||||
}
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
@@ -146,16 +171,16 @@ export async function listMcpServers(settings?: MergedSettings): Promise<void> {
|
||||
let statusText = '';
|
||||
switch (status) {
|
||||
case MCPServerStatus.CONNECTED:
|
||||
statusIndicator = COLOR_GREEN + '✓' + RESET_COLOR;
|
||||
statusIndicator = chalk.green('✓');
|
||||
statusText = 'Connected';
|
||||
break;
|
||||
case MCPServerStatus.CONNECTING:
|
||||
statusIndicator = COLOR_YELLOW + '…' + RESET_COLOR;
|
||||
statusIndicator = chalk.yellow('…');
|
||||
statusText = 'Connecting';
|
||||
break;
|
||||
case MCPServerStatus.DISCONNECTED:
|
||||
default:
|
||||
statusIndicator = COLOR_RED + '✗' + RESET_COLOR;
|
||||
statusIndicator = chalk.red('✗');
|
||||
statusText = 'Disconnected';
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -141,7 +141,10 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getMcpClientManager: vi.fn().mockReturnValue({
|
||||
getMcpInstructions: vi.fn().mockReturnValue(''),
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
getLastError: vi.fn().mockReturnValue(undefined),
|
||||
}),
|
||||
setUserInteractedWithMcp: vi.fn(),
|
||||
emitMcpDiagnostic: vi.fn(),
|
||||
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
|
||||
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
|
||||
getDisabledSkills: vi.fn().mockReturnValue([]),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mcpCommand } from './mcpCommand.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import {
|
||||
@@ -77,6 +77,8 @@ describe('mcpCommand', () => {
|
||||
getGeminiClient: ReturnType<typeof vi.fn>;
|
||||
getMcpClientManager: ReturnType<typeof vi.fn>;
|
||||
getResourceRegistry: ReturnType<typeof vi.fn>;
|
||||
setUserInteractedWithMcp: ReturnType<typeof vi.fn>;
|
||||
getLastMcpError: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -104,12 +106,15 @@ describe('mcpCommand', () => {
|
||||
}),
|
||||
getGeminiClient: vi.fn(),
|
||||
getMcpClientManager: vi.fn().mockImplementation(() => ({
|
||||
getBlockedMcpServers: vi.fn(),
|
||||
getMcpServers: vi.fn(),
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getMcpServers: vi.fn().mockReturnValue({}),
|
||||
getLastError: vi.fn().mockReturnValue(undefined),
|
||||
})),
|
||||
getResourceRegistry: vi.fn().mockReturnValue({
|
||||
getAllResources: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
setUserInteractedWithMcp: vi.fn(),
|
||||
getLastMcpError: vi.fn().mockReturnValue(undefined),
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
@@ -119,6 +124,10 @@ describe('mcpCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should show an error if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
@@ -161,6 +170,7 @@ describe('mcpCommand', () => {
|
||||
mockConfig.getMcpClientManager = vi.fn().mockReturnValue({
|
||||
getMcpServers: vi.fn().mockReturnValue(mockMcpServers),
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getLastError: vi.fn().mockReturnValue(undefined),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ const authCommand: SlashCommand = {
|
||||
};
|
||||
}
|
||||
|
||||
config.setUserInteractedWithMcp();
|
||||
|
||||
const mcpServers = config.getMcpClientManager()?.getMcpServers() ?? {};
|
||||
|
||||
if (!serverName) {
|
||||
@@ -184,6 +186,8 @@ const listAction = async (
|
||||
};
|
||||
}
|
||||
|
||||
config.setUserInteractedWithMcp();
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
@@ -250,6 +254,13 @@ const listAction = async (
|
||||
enablementState[serverName] =
|
||||
await enablementManager.getDisplayState(serverName);
|
||||
}
|
||||
const errors: Record<string, string> = {};
|
||||
for (const serverName of serverNames) {
|
||||
const error = config.getMcpClientManager()?.getLastError(serverName);
|
||||
if (error) {
|
||||
errors[serverName] = error;
|
||||
}
|
||||
}
|
||||
|
||||
const mcpStatusItem: HistoryItemMcpStatus = {
|
||||
type: MessageType.MCP_STATUS,
|
||||
@@ -274,16 +285,19 @@ const listAction = async (
|
||||
})),
|
||||
authStatus,
|
||||
enablementState,
|
||||
blockedServers: blockedMcpServers,
|
||||
errors,
|
||||
blockedServers: blockedMcpServers.map((s) => ({
|
||||
name: s.name,
|
||||
extensionName: s.extensionName,
|
||||
})),
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
showDescriptions,
|
||||
showSchema,
|
||||
showDescriptions: Boolean(showDescriptions),
|
||||
showSchema: Boolean(showSchema),
|
||||
};
|
||||
|
||||
context.ui.addItem(mcpStatusItem);
|
||||
};
|
||||
|
||||
const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
altNames: ['ls', 'nodesc', 'nodescription'],
|
||||
@@ -372,6 +386,8 @@ async function handleEnableDisable(
|
||||
};
|
||||
}
|
||||
|
||||
config.setUserInteractedWithMcp();
|
||||
|
||||
const parts = args.trim().split(/\s+/);
|
||||
const isSession = parts.includes('--session');
|
||||
const serverName = parts.filter((p) => p !== '--session')[0];
|
||||
|
||||
@@ -47,6 +47,7 @@ describe('McpStatus', () => {
|
||||
isPersistentDisabled: false,
|
||||
},
|
||||
},
|
||||
errors: {},
|
||||
discoveryInProgress: false,
|
||||
connectingServers: [],
|
||||
showDescriptions: true,
|
||||
@@ -208,6 +209,18 @@ describe('McpStatus', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('renders correctly with a server error', async () => {
|
||||
const { lastFrame, unmount, waitUntilReady } = render(
|
||||
<McpStatus
|
||||
{...baseProps}
|
||||
errors={{ 'server-1': 'Failed to connect to server' }}
|
||||
/>,
|
||||
);
|
||||
await waitUntilReady();
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('truncates resources when exceeding limit', async () => {
|
||||
const manyResources = Array.from({ length: 25 }, (_, i) => ({
|
||||
serverName: 'server-1',
|
||||
|
||||
@@ -26,6 +26,7 @@ interface McpStatusProps {
|
||||
serverStatus: (serverName: string) => MCPServerStatus;
|
||||
authStatus: HistoryItemMcpStatus['authStatus'];
|
||||
enablementState: HistoryItemMcpStatus['enablementState'];
|
||||
errors: Record<string, string>;
|
||||
discoveryInProgress: boolean;
|
||||
connectingServers: string[];
|
||||
showDescriptions: boolean;
|
||||
@@ -41,6 +42,7 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
||||
serverStatus,
|
||||
authStatus,
|
||||
enablementState,
|
||||
errors,
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
showDescriptions,
|
||||
@@ -195,6 +197,14 @@ export const McpStatus: React.FC<McpStatusProps> = ({
|
||||
<Text> ({toolCount} tools cached)</Text>
|
||||
)}
|
||||
|
||||
{errors[serverName] && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.status.error}>
|
||||
Error: {errors[serverName]}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{showDescriptions && server?.description && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{server.description.trim()}
|
||||
|
||||
@@ -60,6 +60,18 @@ A test server
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`McpStatus > renders correctly with a server error 1`] = `
|
||||
"Configured MCP servers:
|
||||
|
||||
🟢 server-1 - Ready (1 tool)
|
||||
Error: Failed to connect to server
|
||||
A test server
|
||||
Tools:
|
||||
- tool-1
|
||||
A test tool
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`McpStatus > renders correctly with authenticated OAuth status 1`] = `
|
||||
"Configured MCP servers:
|
||||
|
||||
|
||||
@@ -340,6 +340,7 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
|
||||
isPersistentDisabled: boolean;
|
||||
}
|
||||
>;
|
||||
errors: Record<string, string>;
|
||||
blockedServers: Array<{ name: string; extensionName: string }>;
|
||||
discoveryInProgress: boolean;
|
||||
connectingServers: string[];
|
||||
|
||||
Reference in New Issue
Block a user