Files
gemini-cli/packages/cli/src/commands/mcp/remove.test.ts
2025-11-22 02:08:06 +00:00

244 lines
6.8 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import yargs, { type Argv } from 'yargs';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
import { removeCommand } from './remove.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core';
vi.mock('fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
}));
vi.mock('../utils.js', () => ({
exitCli: vi.fn(),
}));
describe('mcp remove command', () => {
describe('unit tests with mocks', () => {
let parser: Argv;
let mockSetValue: Mock;
let mockSettings: Record<string, unknown>;
beforeEach(async () => {
vi.resetAllMocks();
mockSetValue = vi.fn();
mockSettings = {
mcpServers: {
'test-server': {
command: 'echo "hello"',
},
},
};
vi.spyOn(
await import('../../config/settings.js'),
'loadSettings',
).mockReturnValue({
forScope: () => ({ settings: mockSettings }),
setValue: mockSetValue,
workspace: { path: '/path/to/project' },
user: { path: '/home/user' },
} as unknown as LoadedSettings);
const yargsInstance = yargs([]).command(removeCommand);
parser = yargsInstance;
});
it('should remove a server from project settings', async () => {
await parser.parseAsync('remove test-server');
expect(mockSetValue).toHaveBeenCalledWith(
SettingScope.Workspace,
'mcpServers',
{},
);
});
it('should show a message if server not found', async () => {
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove non-existent-server');
expect(mockSetValue).not.toHaveBeenCalled();
expect(debugLogSpy).toHaveBeenCalledWith(
'Server "non-existent-server" not found in project settings.',
);
debugLogSpy.mockRestore();
});
});
describe('integration tests with real file I/O', () => {
let tempDir: string;
let settingsDir: string;
let settingsPath: string;
let parser: Argv;
let cwdSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.resetAllMocks();
vi.restoreAllMocks();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-remove-test-'));
settingsDir = path.join(tempDir, GEMINI_DIR);
settingsPath = path.join(settingsDir, 'settings.json');
fs.mkdirSync(settingsDir, { recursive: true });
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tempDir);
parser = yargs([]).command(removeCommand);
});
afterEach(() => {
cwdSpy.mockRestore();
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it('should actually remove a server from the settings file', async () => {
const originalContent = `{
"mcpServers": {
"server-to-keep": {
"command": "node",
"args": ["keep.js"]
},
"server-to-remove": {
"command": "node",
"args": ["remove.js"]
}
}
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove server-to-remove');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
expect(updatedContent).toContain('"server-to-keep"');
expect(updatedContent).not.toContain('"server-to-remove"');
expect(debugLogSpy).toHaveBeenCalledWith(
'Server "server-to-remove" removed from project settings.',
);
debugLogSpy.mockRestore();
});
it('should preserve comments when removing a server', async () => {
const originalContent = `{
"mcpServers": {
// Server to keep
"context7": {
"command": "node",
"args": ["server.js"]
},
// Server to remove
"oldServer": {
"command": "old",
"args": ["old.js"]
}
}
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove oldServer');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
expect(updatedContent).toContain('// Server to keep');
expect(updatedContent).toContain('"context7"');
expect(updatedContent).not.toContain('"oldServer"');
expect(updatedContent).toContain('// Server to remove');
debugLogSpy.mockRestore();
});
it('should handle removing the only server', async () => {
const originalContent = `{
"mcpServers": {
"only-server": {
"command": "node",
"args": ["server.js"]
}
}
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove only-server');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
expect(updatedContent).toContain('"mcpServers"');
expect(updatedContent).not.toContain('"only-server"');
expect(updatedContent).toMatch(/"mcpServers"\s*:\s*\{\s*\}/);
debugLogSpy.mockRestore();
});
it('should preserve other settings when removing a server', async () => {
// Create settings file with other settings
// Note: "model" will be migrated to "model": { "name": ... } format
const originalContent = `{
"model": {
"name": "gemini-2.5-pro"
},
"mcpServers": {
"server1": {
"command": "node",
"args": ["s1.js"]
},
"server2": {
"command": "node",
"args": ["s2.js"]
}
},
"ui": {
"theme": "dark"
}
}`;
fs.writeFileSync(settingsPath, originalContent, 'utf-8');
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
.mockImplementation(() => {});
await parser.parseAsync('remove server1');
const updatedContent = fs.readFileSync(settingsPath, 'utf-8');
expect(updatedContent).toContain('"model"');
expect(updatedContent).toContain('"gemini-2.5-pro"');
expect(updatedContent).toContain('"server2"');
expect(updatedContent).toContain('"ui"');
expect(updatedContent).toContain('"theme": "dark"');
expect(updatedContent).not.toContain('"server1"');
debugLogSpy.mockRestore();
});
});
});