mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 20:50:34 +00:00
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:
parent
9391779cd0
commit
8a2bda67ed
6 changed files with 874 additions and 95 deletions
|
|
@ -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: () => {
|
||||
|
|
|
|||
235
packages/cli/src/commands/mcp/reconnect.test.ts
Normal file
235
packages/cli/src/commands/mcp/reconnect.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
163
packages/cli/src/commands/mcp/reconnect.ts
Normal file
163
packages/cli/src/commands/mcp/reconnect.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue