feat(mcp): add reconnect command and implement auto-reconnect logic

- Added a new reconnect command to the MCP CLI.
- Implemented auto-reconnect functionality in DiscoveredMCPToolInvocation to handle connection errors with retry logic.
- Enhanced tests to cover reconnect scenarios and ensure reliability during connection failures.
This commit is contained in:
qqqys 2026-03-16 23:25:33 +08:00
parent 9391779cd0
commit 8a2bda67ed
6 changed files with 874 additions and 95 deletions

View file

@ -9,6 +9,7 @@ import type { CommandModule, Argv } from 'yargs';
import { addCommand } from './mcp/add.js';
import { removeCommand } from './mcp/remove.js';
import { listCommand } from './mcp/list.js';
import { reconnectCommand } from './mcp/reconnect.js';
export const mcpCommand: CommandModule = {
command: 'mcp',
@ -18,6 +19,7 @@ export const mcpCommand: CommandModule = {
.command(addCommand)
.command(removeCommand)
.command(listCommand)
.command(reconnectCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {

View file

@ -0,0 +1,235 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { reconnectCommand } from './reconnect.js';
import { loadSettings } from '../../config/settings.js';
import { Config, ExtensionManager } from '@qwen-code/qwen-code-core';
const mockWriteStdoutLine = vi.hoisted(() => vi.fn());
const mockWriteStderrLine = vi.hoisted(() => vi.fn());
const mockProcessExit = vi.hoisted(() => vi.fn());
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: mockWriteStdoutLine,
writeStderrLine: mockWriteStderrLine,
}));
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn().mockReturnValue(true),
}));
vi.mock('@qwen-code/qwen-code-core', () => ({
Config: vi.fn(),
FileDiscoveryService: vi.fn(),
ExtensionManager: vi.fn(),
getErrorMessage: (e: unknown) => (e instanceof Error ? e.message : String(e)),
}));
const mockedLoadSettings = loadSettings as vi.Mock;
const MockedConfig = Config as vi.Mock;
const MockedExtensionManager = ExtensionManager as vi.Mock;
describe('mcp reconnect command', () => {
let mockConfig: {
getToolRegistry: vi.Mock;
shutdown: vi.Mock;
initialize: vi.Mock;
};
let mockToolRegistry: {
discoverToolsForServer: vi.Mock;
};
let mockExtensionManager: {
refreshCache: vi.Mock;
getLoadedExtensions: vi.Mock;
};
beforeEach(() => {
vi.resetAllMocks();
mockWriteStdoutLine.mockClear();
mockWriteStderrLine.mockClear();
mockToolRegistry = {
discoverToolsForServer: vi.fn().mockResolvedValue(undefined),
};
mockConfig = {
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
shutdown: vi.fn().mockResolvedValue(undefined),
initialize: vi.fn().mockResolvedValue(undefined),
};
mockExtensionManager = {
refreshCache: vi.fn().mockResolvedValue(undefined),
getLoadedExtensions: vi.fn().mockReturnValue([]),
};
MockedConfig.mockImplementation(() => mockConfig);
MockedExtensionManager.mockImplementation(() => mockExtensionManager);
Object.defineProperty(process, 'exit', {
value: mockProcessExit,
writable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('reconnect specific server', () => {
it('should successfully reconnect a specific server', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'test-server': { command: '/path/to/server' },
},
},
});
const handler = reconnectCommand.handler as (
argv: Record<string, unknown>,
) => Promise<void>;
await handler({ 'server-name': 'test-server', all: false });
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Reconnecting to server "test-server"...',
);
expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith(
'test-server',
);
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Successfully reconnected to server "test-server".',
);
});
it('should print error when server not found', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'other-server': { command: '/path/to/server' },
},
},
});
const handler = reconnectCommand.handler as (
argv: Record<string, unknown>,
) => Promise<void>;
await handler({ 'server-name': 'nonexistent-server', all: false });
expect(mockWriteStderrLine).toHaveBeenCalledWith(
'Error: Server "nonexistent-server" not found in configuration.',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
it('should print error when reconnection fails', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'test-server': { command: '/path/to/server' },
},
},
});
mockToolRegistry.discoverToolsForServer.mockRejectedValue(
new Error('Connection refused'),
);
const handler = reconnectCommand.handler as (
argv: Record<string, unknown>,
) => Promise<void>;
await handler({ 'server-name': 'test-server', all: false });
expect(mockWriteStderrLine).toHaveBeenCalledWith(
'Failed to reconnect to server "test-server": Connection refused',
);
expect(mockProcessExit).toHaveBeenCalledWith(1);
});
});
describe('reconnect all servers', () => {
it('should successfully reconnect all servers', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'server-one': { command: '/path/to/server1' },
'server-two': { command: '/path/to/server2' },
},
},
});
const handler = reconnectCommand.handler as (
argv: Record<string, unknown>,
) => Promise<void>;
await handler({ 'server-name': undefined, all: true });
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Reconnecting to all MCP servers...\n',
);
expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith(
'server-one',
);
expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith(
'server-two',
);
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'✓ server-one: Reconnected successfully',
);
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'✓ server-two: Reconnected successfully',
);
});
it('should print message when no servers configured', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {},
},
});
const handler = reconnectCommand.handler as (
argv: Record<string, unknown>,
) => Promise<void>;
await handler({ 'server-name': undefined, all: true });
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'No MCP servers configured.',
);
});
it('should report failure for individual servers when reconnecting all', async () => {
mockedLoadSettings.mockReturnValue({
merged: {
mcpServers: {
'server-one': { command: '/path/to/server1' },
'server-two': { command: '/path/to/server2' },
},
},
});
mockToolRegistry.discoverToolsForServer
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error('Timeout'));
const handler = reconnectCommand.handler as (
argv: Record<string, unknown>,
) => Promise<void>;
await handler({ 'server-name': undefined, all: true });
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'✓ server-one: Reconnected successfully',
);
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'✗ server-two: Failed - Timeout',
);
});
});
});

View file

@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { loadSettings } from '../../config/settings.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import {
Config,
FileDiscoveryService,
ExtensionManager,
} from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
async function getMcpServersFromConfig(): Promise<
Record<string, MCPServerConfig>
> {
const settings = loadSettings();
const extensionManager = new ExtensionManager({
isWorkspaceTrusted: !!isWorkspaceTrusted(settings.merged),
telemetrySettings: settings.merged.telemetry,
});
await extensionManager.refreshCache();
const extensions = extensionManager.getLoadedExtensions();
const mcpServers = { ...(settings.merged.mcpServers || {}) };
for (const extension of extensions) {
if (extension.isActive) {
Object.entries(extension.config.mcpServers || {}).forEach(
([key, server]) => {
if (mcpServers[key]) {
return;
}
mcpServers[key] = {
...server,
extensionName: extension.config.name,
};
},
);
}
}
return mcpServers;
}
async function createMinimalConfig(): Promise<Config> {
const settings = loadSettings();
const cwd = process.cwd();
const fileService = new FileDiscoveryService(cwd);
const config = new Config({
sessionId: 'mcp-reconnect',
targetDir: cwd,
cwd,
debugMode: false,
mcpServers: settings.merged.mcpServers || {},
fileDiscoveryService: fileService,
mcpServerCommand: settings.merged.mcp?.serverCommand,
});
await config.initialize();
return config;
}
async function reconnectMcpServer(serverName: string): Promise<void> {
const mcpServers = await getMcpServersFromConfig();
if (!mcpServers[serverName]) {
writeStderrLine(
`Error: Server "${serverName}" not found in configuration.`,
);
process.exit(1);
}
writeStdoutLine(`Reconnecting to server "${serverName}"...`);
try {
const config = await createMinimalConfig();
const toolRegistry = config.getToolRegistry();
await toolRegistry.discoverToolsForServer(serverName);
writeStdoutLine(`Successfully reconnected to server "${serverName}".`);
await config.shutdown();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
writeStderrLine(
`Failed to reconnect to server "${serverName}": ${message}`,
);
process.exit(1);
}
}
async function reconnectAllMcpServers(): Promise<void> {
const mcpServers = await getMcpServersFromConfig();
const serverNames = Object.keys(mcpServers);
if (serverNames.length === 0) {
writeStdoutLine('No MCP servers configured.');
return;
}
writeStdoutLine('Reconnecting to all MCP servers...\n');
let config: Config | undefined;
try {
config = await createMinimalConfig();
const toolRegistry = config.getToolRegistry();
for (const serverName of serverNames) {
try {
await toolRegistry.discoverToolsForServer(serverName);
writeStdoutLine(`${serverName}: Reconnected successfully`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
writeStdoutLine(`${serverName}: Failed - ${message}`);
}
}
} finally {
if (config) {
await config.shutdown();
}
}
}
export const reconnectCommand: CommandModule = {
command: 'reconnect [server-name]',
describe: 'Reconnect MCP server(s)',
builder: (yargs) =>
yargs
.usage('Usage: qwen mcp reconnect [options] [server-name]')
.positional('server-name', {
describe: 'Name of the server to reconnect',
type: 'string',
})
.option('all', {
alias: 'a',
describe: 'Reconnect all configured servers',
type: 'boolean',
default: false,
})
.conflicts('server-name', 'all')
.check((argv) => {
const serverName = argv['server-name'];
const all = argv['all'];
if (!serverName && !all) {
throw new Error(
'Please specify a server name or use --all to reconnect all servers.',
);
}
return true;
}),
handler: async (argv) => {
const serverName = argv['server-name'] as string | undefined;
const all = argv['all'] as boolean;
if (all) {
await reconnectAllMcpServers();
} else if (serverName) {
await reconnectMcpServer(serverName);
}
},
};

View file

@ -23,6 +23,7 @@
"src/commands/mcp/add.test.ts",
"src/commands/mcp/list.test.ts",
"src/commands/mcp/remove.test.ts",
"src/commands/mcp/reconnect.test.ts",
"src/config/config.integration.test.ts",
"src/config/config.test.ts",
"src/config/extension.test.ts",