diff --git a/docs/developers/tools/mcp-server.md b/docs/developers/tools/mcp-server.md index 8d48970a78..ecf09a580b 100644 --- a/docs/developers/tools/mcp-server.md +++ b/docs/developers/tools/mcp-server.md @@ -834,23 +834,25 @@ qwen mcp add --transport sse sse-server https://api.example.com/sse/ qwen mcp add --transport sse secure-sse https://api.example.com/sse/ --header "Authorization: Bearer abc123" ``` -### Listing Servers (`qwen mcp list`) +### Managing Servers (`qwen mcp`) -To view all MCP servers currently configured, use the `list` command. It displays each server's name, configuration details, and connection status. +To view and manage all MCP servers currently configured, use the `manage` command or simply `qwen mcp`. This opens an interactive TUI dialog where you can: + +- View all MCP servers with their connection status +- Enable/disable servers +- Reconnect to disconnected servers +- View tools and prompts provided by each server +- View server logs **Command:** ```bash -qwen mcp list +qwen mcp +# or +qwen mcp manage ``` -**Example Output:** - -```sh -✓ stdio-server: command: python3 server.py (stdio) - Connected -✓ http-server: https://api.example.com/mcp (http) - Connected -✗ sse-server: https://api.example.com/sse (sse) - Disconnected -``` +The management dialog provides a visual interface showing each server's name, configuration details, connection status, and available tools/prompts. ### Removing a Server (`qwen mcp remove`) diff --git a/docs/users/features/mcp.md b/docs/users/features/mcp.md index 2b123c12c6..534e1195c1 100644 --- a/docs/users/features/mcp.md +++ b/docs/users/features/mcp.md @@ -30,10 +30,10 @@ Qwen Code loads MCP servers from `mcpServers` in your `settings.json`. You can c qwen mcp add --transport http my-server http://localhost:3000/mcp ``` -2. Verify it shows up: +2. Open MCP management dialog to view and manage servers: ```bash -qwen mcp list +qwen mcp ``` 3. Restart Qwen Code in the same project (or start it if it wasn’t running yet), then ask the model to use tools from that server. @@ -274,12 +274,6 @@ qwen mcp add [options] [args...] | `--include-tools` | A comma-separated list of tools to include. | all tools included | `--include-tools mytool,othertool` | | `--exclude-tools` | A comma-separated list of tools to exclude. | none | `--exclude-tools mytool` | -#### Listing servers (`qwen mcp list`) - -```bash -qwen mcp list -``` - #### Removing a server (`qwen mcp remove`) ```bash diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 34608b2100..644fc050cd 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -1322,7 +1322,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { }); }); - it('should read excludeMCPServers from settings', async () => { + it('should read excludeMCPServers from settings but still return all servers', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -1330,12 +1330,18 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { mcp: { excluded: ['server1', 'server2'] }, }; const config = await loadCliConfig(settings, argv, undefined, []); + // getMcpServers() now returns all servers, use isMcpServerDisabled() to check status expect(config.getMcpServers()).toEqual({ + server1: { url: 'http://localhost:8080' }, + server2: { url: 'http://localhost:8081' }, server3: { url: 'http://localhost:8082' }, }); + expect(config.isMcpServerDisabled('server1')).toBe(true); + expect(config.isMcpServerDisabled('server2')).toBe(true); + expect(config.isMcpServerDisabled('server3')).toBe(false); }); - it('should override allowMCPServers with excludeMCPServers if overlapping', async () => { + it('should apply allowedMcpServers filter but excluded servers are still returned', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); const settings: Settings = { @@ -1346,9 +1352,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => { }, }; const config = await loadCliConfig(settings, argv, undefined, []); + // allowedMcpServers filters which servers are available + // but excluded servers are still returned by getMcpServers() expect(config.getMcpServers()).toEqual({ + server1: { url: 'http://localhost:8080' }, server2: { url: 'http://localhost:8081' }, }); + expect(config.isMcpServerDisabled('server1')).toBe(true); + expect(config.isMcpServerDisabled('server2')).toBe(false); }); it('should prioritize mcp server flag if set', async () => { diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 1562f5884d..a5af9d471e 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -97,7 +97,7 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.', - 'list available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]': 'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]', 'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:', 'No tools available': 'Keine Werkzeuge verfügbar', @@ -360,7 +360,9 @@ export default { 'Show tool-specific usage statistics.': 'Werkzeugspezifische Nutzungsstatistiken anzeigen.', 'exit the cli': 'CLI beenden', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'MCP-Verwaltungsdialog öffnen oder mit OAuth-fähigem Server authentifizieren', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': 'Konfigurierte MCP-Server und Werkzeuge auflisten oder mit OAuth-fähigen Servern authentifizieren', 'Manage workspace directories': 'Arbeitsbereichsverzeichnisse verwalten', 'Add directories to the workspace. Use comma to separate multiple paths': @@ -882,9 +884,101 @@ export default { 'Do you want to proceed?': 'Möchten Sie fortfahren?', 'Yes, allow once': 'Ja, einmal erlauben', 'Allow always': 'Immer erlauben', + Yes: 'Ja', No: 'Nein', 'No (esc)': 'Nein (Esc)', 'Yes, allow always for this session': 'Ja, für diese Sitzung immer erlauben', + + // MCP Management Dialog (translations for MCP UI components) + 'Manage MCP servers': 'MCP-Server verwalten', + 'Server Detail': 'Serverdetails', + 'Disable Server': 'Server deaktivieren', + Tools: 'Werkzeuge', + 'Tool Detail': 'Werkzeugdetails', + 'MCP Management': 'MCP-Verwaltung', + 'Loading...': 'Lädt...', + 'Unknown step': 'Unbekannter Schritt', + 'Esc to back': 'Esc zurück', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ navigieren · Enter auswählen · Esc schließen', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ navigieren · Enter auswählen · Esc zurück', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ navigieren · Enter bestätigen · Esc zurück', + 'User Settings (global)': 'Benutzereinstellungen (global)', + 'Workspace Settings (project-specific)': + 'Arbeitsbereichseinstellungen (projektspezifisch)', + 'Disable server:': 'Server deaktivieren:', + 'Select where to add the server to the exclude list:': + 'Wählen Sie, wo der Server zur Ausschlussliste hinzugefügt werden soll:', + 'Press Enter to confirm, Esc to cancel': + 'Enter zum Bestätigen, Esc zum Abbrechen', + Disable: 'Deaktivieren', + Enable: 'Aktivieren', + Reconnect: 'Neu verbinden', + 'View tools': 'Werkzeuge anzeigen', + 'Status:': 'Status:', + 'Command:': 'Befehl:', + 'Working Directory:': 'Arbeitsverzeichnis:', + 'Capabilities:': 'Fähigkeiten:', + 'No server selected': 'Kein Server ausgewählt', + '(disabled)': '(deaktiviert)', + 'Error:': 'Fehler:', + Extension: 'Erweiterung', + tool: 'Werkzeug', + tools: 'Werkzeuge', + connected: 'verbunden', + connecting: 'verbindet', + disconnected: 'getrennt', + error: 'Fehler', + + // MCP Server List + 'User MCPs': 'Benutzer-MCPs', + 'Project MCPs': 'Projekt-MCPs', + 'Extension MCPs': 'Erweiterungs-MCPs', + server: 'Server', + servers: 'Server', + 'Add MCP servers to your settings to get started.': + 'Fügen Sie MCP-Server zu Ihren Einstellungen hinzu, um zu beginnen.', + 'Run qwen --debug to see error logs': + 'Führen Sie qwen --debug aus, um Fehlerprotokolle anzuzeigen', + + // MCP Tool List + 'No tools available for this server.': + 'Keine Werkzeuge für diesen Server verfügbar.', + destructive: 'destruktiv', + 'read-only': 'schreibgeschützt', + 'open-world': 'offene Welt', + idempotent: 'idempotent', + 'Tools for {{name}}': 'Werkzeuge für {{name}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'erforderlich', + Type: 'Typ', + Enum: 'Aufzählung', + Parameters: 'Parameter', + 'No tool selected': 'Kein Werkzeug ausgewählt', + Annotations: 'Anmerkungen', + Title: 'Titel', + 'Read Only': 'Schreibgeschützt', + Destructive: 'Destruktiv', + Idempotent: 'Idempotent', + 'Open World': 'Offene Welt', + Server: 'Server', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} ungültige Werkzeuge', + invalid: 'ungültig', + 'invalid: {{reason}}': 'ungültig: {{reason}}', + 'missing name': 'Name fehlt', + 'missing description': 'Beschreibung fehlt', + '(unnamed)': '(unbenannt)', + 'Warning: This tool cannot be called by the LLM': + 'Warnung: Dieses Werkzeug kann nicht vom LLM aufgerufen werden', + Reason: 'Grund', + 'Tools must have both name and description to be used by the LLM.': + 'Werkzeuge müssen sowohl einen Namen als auch eine Beschreibung haben, um vom LLM verwendet zu werden.', 'Modify in progress:': 'Änderung in Bearbeitung:', 'Save and close external editor to continue': 'Speichern und externen Editor schließen, um fortzufahren', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 6904191728..94a3fe7e0e 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -116,8 +116,8 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'Analyzes the project and creates a tailored QWEN.md file.', - 'list available Qwen Code tools. Usage: /tools [desc]': - 'list available Qwen Code tools. Usage: /tools [desc]', + 'List available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]', 'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:', 'No tools available': 'No tools available', 'View or change the approval mode for tool usage': @@ -376,8 +376,10 @@ export default { 'Show tool-specific usage statistics.': 'Show tool-specific usage statistics.', 'exit the cli': 'exit the cli', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers', 'Manage workspace directories': 'Manage workspace directories', 'Add directories to the workspace. Use comma to separate multiple paths': 'Add directories to the workspace. Use comma to separate multiple paths', @@ -726,6 +728,7 @@ export default { 'List configured MCP servers and tools': 'List configured MCP servers and tools', 'Restarts MCP servers.': 'Restarts MCP servers.', + 'Open MCP management dialog': 'Open MCP management dialog', 'Config not loaded.': 'Config not loaded.', 'Could not retrieve tool registry.': 'Could not retrieve tool registry.', 'No MCP servers configured with OAuth authentication.': @@ -742,6 +745,98 @@ export default { "Re-discovering tools from '{{name}}'...": "Re-discovering tools from '{{name}}'...", + // ============================================================================ + // MCP Management Dialog + // ============================================================================ + 'Manage MCP servers': 'Manage MCP servers', + 'Server Detail': 'Server Detail', + 'Disable Server': 'Disable Server', + Tools: 'Tools', + 'Tool Detail': 'Tool Detail', + 'MCP Management': 'MCP Management', + 'Loading...': 'Loading...', + 'Unknown step': 'Unknown step', + 'Esc to back': 'Esc to back', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ to navigate · Enter to select · Esc to close', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ to navigate · Enter to select · Esc to back', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ to navigate · Enter to confirm · Esc to back', + 'User Settings (global)': 'User Settings (global)', + 'Workspace Settings (project-specific)': + 'Workspace Settings (project-specific)', + 'Disable server:': 'Disable server:', + 'Select where to add the server to the exclude list:': + 'Select where to add the server to the exclude list:', + 'Press Enter to confirm, Esc to cancel': + 'Press Enter to confirm, Esc to cancel', + 'View tools': 'View tools', + Reconnect: 'Reconnect', + Enable: 'Enable', + Disable: 'Disable', + 'Status:': 'Status:', + 'Command:': 'Command:', + 'Working Directory:': 'Working Directory:', + 'Capabilities:': 'Capabilities:', + 'No server selected': 'No server selected', + prompts: 'prompts', + '(disabled)': '(disabled)', + 'Error:': 'Error:', + Extension: 'Extension', + tool: 'tool', + tools: 'tools', + connected: 'connected', + connecting: 'connecting', + disconnected: 'disconnected', + error: 'error', + + // MCP Server List + 'User MCPs': 'User MCPs', + 'Project MCPs': 'Project MCPs', + 'Extension MCPs': 'Extension MCPs', + server: 'server', + servers: 'servers', + 'Add MCP servers to your settings to get started.': + 'Add MCP servers to your settings to get started.', + 'Run qwen --debug to see error logs': 'Run qwen --debug to see error logs', + + // MCP Tool List + 'No tools available for this server.': 'No tools available for this server.', + destructive: 'destructive', + 'read-only': 'read-only', + 'open-world': 'open-world', + idempotent: 'idempotent', + 'Tools for {{name}}': 'Tools for {{name}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'required', + Type: 'Type', + Enum: 'Enum', + Parameters: 'Parameters', + 'No tool selected': 'No tool selected', + Annotations: 'Annotations', + Title: 'Title', + 'Read Only': 'Read Only', + Destructive: 'Destructive', + Idempotent: 'Idempotent', + 'Open World': 'Open World', + Server: 'Server', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} invalid tools', + invalid: 'invalid', + 'invalid: {{reason}}': 'invalid: {{reason}}', + 'missing name': 'missing name', + 'missing description': 'missing description', + '(unnamed)': '(unnamed)', + 'Warning: This tool cannot be called by the LLM': + 'Warning: This tool cannot be called by the LLM', + Reason: 'Reason', + 'Tools must have both name and description to be used by the LLM.': + 'Tools must have both name and description to be used by the LLM.', + // ============================================================================ // Commands - Chat // ============================================================================ @@ -874,6 +969,7 @@ export default { 'Do you want to proceed?': 'Do you want to proceed?', 'Yes, allow once': 'Yes, allow once', 'Allow always': 'Allow always', + Yes: 'Yes', No: 'No', 'No (esc)': 'No (esc)', 'Yes, allow always for this session': 'Yes, allow always for this session', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 991aadb0fe..b577e2cc12 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -83,7 +83,7 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成', - 'list available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]': '利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]', 'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:', 'No tools available': '利用可能なツールはありません', @@ -317,7 +317,9 @@ export default { 'セッション統計を確認。使い方: /stats [model|tools]', 'Show model-specific usage statistics.': 'モデル別の使用統計を表示', 'Show tool-specific usage statistics.': 'ツール別の使用統計を表示', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'MCP管理ダイアログを開く、またはOAuth対応サーバーで認証', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': '設定済みのMCPサーバーとツールを一覧表示、またはOAuth対応サーバーで認証', 'Manage workspace directories': 'ワークスペースディレクトリを管理', 'Add directories to the workspace. Use comma to separate multiple paths': @@ -622,9 +624,101 @@ export default { 'Do you want to proceed?': '続行しますか?', 'Yes, allow once': 'はい(今回のみ許可)', 'Allow always': '常に許可する', + Yes: 'はい', No: 'いいえ', 'No (esc)': 'いいえ (Esc)', 'Yes, allow always for this session': 'はい、このセッションで常に許可', + + // MCP Management - Core translations + 'Manage MCP servers': 'MCPサーバーを管理', + 'Server Detail': 'サーバー詳細', + 'Disable Server': 'サーバーを無効化', + Tools: 'ツール', + 'Tool Detail': 'ツール詳細', + 'MCP Management': 'MCP管理', + 'Loading...': '読み込み中...', + 'Unknown step': '不明なステップ', + 'Esc to back': 'Esc 戻る', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ ナビゲート · Enter 選択 · Esc 閉じる', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ ナビゲート · Enter 選択 · Esc 戻る', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ ナビゲート · Enter 確認 · Esc 戻る', + 'User Settings (global)': 'ユーザー設定(グローバル)', + 'Workspace Settings (project-specific)': + 'ワークスペース設定(プロジェクト固有)', + 'Disable server:': 'サーバーを無効化:', + 'Select where to add the server to the exclude list:': + 'サーバーを除外リストに追加する場所を選択してください:', + 'Press Enter to confirm, Esc to cancel': 'Enter で確認、Esc でキャンセル', + Disable: '無効化', + Enable: '有効化', + Reconnect: '再接続', + 'View tools': 'ツールを表示', + 'Status:': 'ステータス:', + 'Source:': 'ソース:', + 'Command:': 'コマンド:', + 'Working Directory:': '作業ディレクトリ:', + 'Capabilities:': '機能:', + 'No server selected': 'サーバーが選択されていません', + '(disabled)': '(無効)', + 'Error:': 'エラー:', + Extension: '拡張機能', + tool: 'ツール', + tools: 'ツール', + connected: '接続済み', + connecting: '接続中', + disconnected: '切断済み', + error: 'エラー', + + // MCP Server List + 'User MCPs': 'ユーザーMCP', + 'Project MCPs': 'プロジェクトMCP', + 'Extension MCPs': '拡張機能MCP', + server: 'サーバー', + servers: 'サーバー', + 'Add MCP servers to your settings to get started.': + '設定にMCPサーバーを追加して開始してください。', + 'Run qwen --debug to see error logs': + 'qwen --debug を実行してエラーログを確認してください', + + // MCP Tool List + 'No tools available for this server.': + 'このサーバーには使用可能なツールがありません。', + destructive: '破壊的', + 'read-only': '読み取り専用', + 'open-world': 'オープンワールド', + idempotent: '冪等', + 'Tools for {{name}}': '{{name}} のツール', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: '必須', + Type: '型', + Enum: '列挙', + Parameters: 'パラメータ', + 'No tool selected': 'ツールが選択されていません', + Annotations: '注釈', + Title: 'タイトル', + 'Read Only': '読み取り専用', + Destructive: '破壊的', + Idempotent: '冪等', + 'Open World': 'オープンワールド', + Server: 'サーバー', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} 個の無効なツール', + invalid: '無効', + 'invalid: {{reason}}': '無効: {{reason}}', + 'missing name': '名前なし', + 'missing description': '説明なし', + '(unnamed)': '(名前なし)', + 'Warning: This tool cannot be called by the LLM': + '警告: このツールはLLMによって呼び出すことができません', + Reason: '理由', + 'Tools must have both name and description to be used by the LLM.': + 'ツールはLLMによって使用されるには名前と説明の両方が必要です。', 'Modify in progress:': '変更中:', 'Save and close external editor to continue': '続行するには外部エディタを保存して閉じてください', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 2cbead5e37..c1503e8105 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -109,8 +109,8 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'Analisa o projeto e cria um arquivo QWEN.md personalizado.', - 'list available Qwen Code tools. Usage: /tools [desc]': - 'listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]', + 'List available Qwen Code tools. Usage: /tools [desc]': + 'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]', 'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:', 'No tools available': 'Nenhuma ferramenta disponível', 'View or change the approval mode for tool usage': @@ -385,8 +385,10 @@ export default { 'Show tool-specific usage statistics.': 'Mostrar estatísticas de uso específicas da ferramenta.', 'exit the cli': 'sair da cli', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': - 'listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'Abrir diálogo de gerenciamento MCP ou autenticar com servidor habilitado para OAuth', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth', 'Manage workspace directories': 'Gerenciar diretórios do workspace', 'Add directories to the workspace. Use comma to separate multiple paths': 'Adicionar diretórios ao workspace. Use vírgula para separar vários caminhos', @@ -888,9 +890,102 @@ export default { 'Do you want to proceed?': 'Você deseja prosseguir?', 'Yes, allow once': 'Sim, permitir uma vez', 'Allow always': 'Permitir sempre', + Yes: 'Sim', No: 'Não', 'No (esc)': 'Não (esc)', 'Yes, allow always for this session': 'Sim, permitir sempre para esta sessão', + + // MCP Management - Core translations + 'Manage MCP servers': 'Gerenciar servidores MCP', + 'Server Detail': 'Detalhes do servidor', + 'Disable Server': 'Desativar servidor', + Tools: 'Ferramentas', + 'Tool Detail': 'Detalhes da ferramenta', + 'MCP Management': 'Gerenciamento MCP', + 'Loading...': 'Carregando...', + 'Unknown step': 'Etapa desconhecida', + 'Esc to back': 'Esc para voltar', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ navegar · Enter selecionar · Esc fechar', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ navegar · Enter selecionar · Esc voltar', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ navegar · Enter confirmar · Esc voltar', + 'User Settings (global)': 'Configurações do usuário (global)', + 'Workspace Settings (project-specific)': + 'Configurações do workspace (específico do projeto)', + 'Disable server:': 'Desativar servidor:', + 'Select where to add the server to the exclude list:': + 'Selecione onde adicionar o servidor à lista de exclusão:', + 'Press Enter to confirm, Esc to cancel': + 'Enter para confirmar, Esc para cancelar', + Disable: 'Desativar', + Enable: 'Ativar', + Reconnect: 'Reconectar', + 'View tools': 'Ver ferramentas', + 'Status:': 'Status:', + 'Source:': 'Fonte:', + 'Command:': 'Comando:', + 'Working Directory:': 'Diretório de trabalho:', + 'Capabilities:': 'Capacidades:', + 'No server selected': 'Nenhum servidor selecionado', + '(disabled)': '(desativado)', + 'Error:': 'Erro:', + Extension: 'Extensão', + tool: 'ferramenta', + tools: 'ferramentas', + connected: 'conectado', + connecting: 'conectando', + disconnected: 'desconectado', + error: 'erro', + + // MCP Server List + 'User MCPs': 'MCPs do usuário', + 'Project MCPs': 'MCPs do projeto', + 'Extension MCPs': 'MCPs de extensão', + server: 'servidor', + servers: 'servidores', + 'Add MCP servers to your settings to get started.': + 'Adicione servidores MCP às suas configurações para começar.', + 'Run qwen --debug to see error logs': + 'Execute qwen --debug para ver os logs de erro', + + // MCP Tool List + 'No tools available for this server.': + 'Nenhuma ferramenta disponível para este servidor.', + destructive: 'destrutivo', + 'read-only': 'somente leitura', + 'open-world': 'mundo aberto', + idempotent: 'idempotente', + 'Tools for {{name}}': 'Ferramentas para {{name}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'obrigatório', + Type: 'Tipo', + Enum: 'Enumeração', + Parameters: 'Parâmetros', + 'No tool selected': 'Nenhuma ferramenta selecionada', + Annotations: 'Anotações', + Title: 'Título', + 'Read Only': 'Somente leitura', + Destructive: 'Destrutivo', + Idempotent: 'Idempotente', + 'Open World': 'Mundo aberto', + Server: 'Servidor', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} ferramentas inválidas', + invalid: 'inválido', + 'invalid: {{reason}}': 'inválido: {{reason}}', + 'missing name': 'nome ausente', + 'missing description': 'descrição ausente', + '(unnamed)': '(sem nome)', + 'Warning: This tool cannot be called by the LLM': + 'Aviso: Esta ferramenta não pode ser chamada pelo LLM', + Reason: 'Motivo', + 'Tools must have both name and description to be used by the LLM.': + 'As ferramentas devem ter tanto nome quanto descrição para serem usadas pelo LLM.', 'Modify in progress:': 'Modificação em progresso:', 'Save and close external editor to continue': 'Salve e feche o editor externo para continuar', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index df6240787d..60b63880fb 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -117,7 +117,7 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': 'Анализ проекта и создание адаптированного файла QWEN.md', - 'list available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]': 'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]', 'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:', 'No tools available': 'Нет доступных инструментов', @@ -380,7 +380,9 @@ export default { 'Show tool-specific usage statistics.': 'Показать статистику использования инструментов.', 'exit the cli': 'Выход из CLI', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + 'Открыть диалог управления MCP или авторизоваться на сервере с поддержкой OAuth', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': 'Показать настроенные MCP-серверы и инструменты, или авторизоваться на серверах с поддержкой OAuth', 'Manage workspace directories': 'Управление директориями рабочего пространства', @@ -889,9 +891,36 @@ export default { 'Do you want to proceed?': 'Вы хотите продолжить?', 'Yes, allow once': 'Да, разрешить один раз', 'Allow always': 'Всегда разрешать', + Yes: 'Да', No: 'Нет', 'No (esc)': 'Нет (esc)', 'Yes, allow always for this session': 'Да, всегда разрешать для этой сессии', + + // MCP Management - Core translations + Disable: 'Отключить', + Enable: 'Включить', + Reconnect: 'Переподключить', + 'View tools': 'Просмотреть инструменты', + '(disabled)': '(отключен)', + 'Error:': 'Ошибка:', + Extension: 'Расширение', + tool: 'инструмент', + connected: 'подключен', + connecting: 'подключение', + disconnected: 'отключен', + error: 'ошибка', + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} недействительных инструментов', + invalid: 'недействительный', + 'invalid: {{reason}}': 'недействительно: {{reason}}', + 'missing name': 'отсутствует имя', + 'missing description': 'отсутствует описание', + '(unnamed)': '(без имени)', + 'Warning: This tool cannot be called by the LLM': + 'Предупреждение: Этот инструмент не может быть вызван LLM', + Reason: 'Причина', + 'Tools must have both name and description to be used by the LLM.': + 'Инструменты должны иметь как имя, так и описание, чтобы использоваться LLM.', 'Modify in progress:': 'Идет изменение:', 'Save and close external editor to continue': 'Сохраните и закройте внешний редактор для продолжения', @@ -1461,6 +1490,75 @@ export default { 'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?', '{{region}} configuration updated successfully. Model switched to "{{model}}".': 'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".', + 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': + 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).', + + // ============================================================================ + // MCP Management Dialog + // ============================================================================ + 'MCP Management': 'Управление MCP', + 'Server List': 'Список серверов', + 'Server Detail': 'Детали сервера', + 'Disable Server': 'Отключить сервер', + 'Tool List': 'Список инструментов', + 'Tool Detail': 'Детали инструмента', + 'Loading...': 'Загрузка...', + 'Unknown step': 'Неизвестный шаг', + 'Esc to back': 'Esc для возврата', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ навигация · Enter выбрать · Esc закрыть', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ навигация · Enter выбрать · Esc назад', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ навигация · Enter подтвердить · Esc назад', + 'User Settings (global)': 'Настройки пользователя (глобальные)', + 'Workspace Settings (project-specific)': + 'Настройки рабочего пространства (проектные)', + 'Disable server:': 'Отключить сервер:', + 'Select where to add the server to the exclude list:': + 'Выберите, где добавить сервер в список исключений:', + 'Press Enter to confirm, Esc to cancel': + 'Enter для подтверждения, Esc для отмены', + 'Status:': 'Статус:', + 'Command:': 'Команда:', + 'Working Directory:': 'Рабочий каталог:', + 'Capabilities:': 'Возможности:', + 'No server selected': 'Сервер не выбран', + + // MCP Server List + 'User MCPs': 'MCP пользователя', + 'Project MCPs': 'MCP проекта', + 'Extension MCPs': 'MCP расширений', + server: 'сервер', + servers: 'серверов', + 'Add MCP servers to your settings to get started.': + 'Добавьте серверы MCP в настройки, чтобы начать.', + 'Run qwen --debug to see error logs': + 'Запустите qwen --debug для просмотра журналов ошибок', + + // MCP Tool List + 'No tools available for this server.': + 'Для этого сервера нет доступных инструментов.', + destructive: 'деструктивный', + 'read-only': 'только чтение', + 'open-world': 'открытый мир', + idempotent: 'идемпотентный', + 'Tools for {{name}}': 'Инструменты для {{name}}', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + required: 'обязательный', + Type: 'Тип', + Enum: 'Перечисление', + Parameters: 'Параметры', + 'No tool selected': 'Инструмент не выбран', + Annotations: 'Аннотации', + Title: 'Заголовок', + 'Read Only': 'Только чтение', + Destructive: 'Деструктивный', + Idempotent: 'Идемпотентный', + 'Open World': 'Открытый мир', + Server: 'Сервер', '{{region}} configuration updated successfully.': 'Конфигурация {{region}} успешно обновлена.', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index b2b17d9808..351e0931a9 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -114,7 +114,7 @@ export default { // ============================================================================ 'Analyzes the project and creates a tailored QWEN.md file.': '分析项目并创建定制的 QWEN.md 文件', - 'list available Qwen Code tools. Usage: /tools [desc]': + 'List available Qwen Code tools. Usage: /tools [desc]': '列出可用的 Qwen Code 工具。用法:/tools [desc]', 'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:', 'No tools available': '没有可用工具', @@ -361,7 +361,9 @@ export default { 'Show model-specific usage statistics.': '显示模型相关的使用统计信息', 'Show tool-specific usage statistics.': '显示工具相关的使用统计信息', 'exit the cli': '退出命令行界面', - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'Open MCP management dialog, or authenticate with OAuth-enabled servers': + '打开 MCP 管理对话框,或在支持 OAuth 的服务器上进行身份验证', + 'List configured MCP servers and tools, or authenticate with OAuth-enabled servers': '列出已配置的 MCP 服务器和工具,或使用支持 OAuth 的服务器进行身份验证', 'Manage workspace directories': '管理工作区目录', 'Add directories to the workspace. Use comma to separate multiple paths': @@ -685,6 +687,7 @@ export default { '使用支持 OAuth 的 MCP 服务器进行认证', 'List configured MCP servers and tools': '列出已配置的 MCP 服务器和工具', 'Restarts MCP servers.': '重启 MCP 服务器', + 'Open MCP management dialog': '打开 MCP 管理对话框', 'Config not loaded.': '配置未加载', 'Could not retrieve tool registry.': '无法检索工具注册表', 'No MCP servers configured with OAuth authentication.': @@ -700,6 +703,94 @@ export default { "Re-discovering tools from '{{name}}'...": "正在重新发现 '{{name}}' 的工具...", + // ============================================================================ + // MCP Management Dialog + // ============================================================================ + 'Manage MCP servers': '管理 MCP 服务器', + 'Server Detail': '服务器详情', + 'Disable Server': '禁用服务器', + Tools: '工具', + 'Tool Detail': '工具详情', + 'MCP Management': 'MCP 管理', + 'Loading...': '加载中...', + 'Unknown step': '未知步骤', + 'Esc to back': 'Esc 返回', + '↑↓ to navigate · Enter to select · Esc to close': + '↑↓ 导航 · Enter 选择 · Esc 关闭', + '↑↓ to navigate · Enter to select · Esc to back': + '↑↓ 导航 · Enter 选择 · Esc 返回', + '↑↓ to navigate · Enter to confirm · Esc to back': + '↑↓ 导航 · Enter 确认 · Esc 返回', + 'User Settings (global)': '用户设置(全局)', + 'Workspace Settings (project-specific)': '工作区设置(项目级)', + 'Disable server:': '禁用服务器:', + 'Select where to add the server to the exclude list:': + '选择将服务器添加到排除列表的位置:', + 'Press Enter to confirm, Esc to cancel': '按 Enter 确认,Esc 取消', + 'View tools': '查看工具', + Reconnect: '重新连接', + Enable: '启用', + Disable: '禁用', + '(disabled)': '(已禁用)', + 'Error:': '错误:', + Extension: '扩展', + tool: '工具', + tools: '个工具', + connected: '已连接', + connecting: '连接中', + disconnected: '已断开', + error: '错误', + + // MCP Server List + 'User MCPs': '用户 MCP', + 'Project MCPs': '项目 MCP', + 'Extension MCPs': '扩展 MCP', + server: '个服务器', + servers: '个服务器', + 'Add MCP servers to your settings to get started.': + '请在设置中添加 MCP 服务器以开始使用。', + 'Run qwen --debug to see error logs': '运行 qwen --debug 查看错误日志', + + // MCP Server Detail + 'Status:': '状态:', + 'Command:': '命令:', + 'Working Directory:': '工作目录:', + 'Capabilities:': '功能:', + + // MCP Tool List + 'No tools available for this server.': '此服务器没有可用工具。', + destructive: '破坏性', + 'read-only': '只读', + 'open-world': '开放世界', + idempotent: '幂等', + 'Tools for {{name}}': '{{name}} 的工具', + '{{current}}/{{total}}': '{{current}}/{{total}}', + + // MCP Tool Detail + Type: '类型', + Parameters: '参数', + 'No tool selected': '未选择工具', + Annotations: '注解', + Title: '标题', + 'Read Only': '只读', + Destructive: '破坏性', + Idempotent: '幂等', + 'Open World': '开放世界', + Server: '服务器', + + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} 个无效工具', + invalid: '无效', + 'invalid: {{reason}}': '无效:{{reason}}', + 'missing name': '缺少名称', + 'missing description': '缺少描述', + '(unnamed)': '(未命名)', + 'Warning: This tool cannot be called by the LLM': + '警告:此工具无法被 LLM 调用', + Reason: '原因', + 'Tools must have both name and description to be used by the LLM.': + '工具必须同时具有名称和描述才能被 LLM 使用。', + // ============================================================================ // Commands - Chat // ============================================================================ @@ -825,6 +916,7 @@ export default { 'Do you want to proceed?': '是否继续?', 'Yes, allow once': '是,允许一次', 'Allow always': '总是允许', + Yes: '是', No: '否', 'No (esc)': '否 (esc)', 'Yes, allow always for this session': '是,本次会话总是允许', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 781aab3753..7acc11b158 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -102,6 +102,7 @@ import { useDialogClose } from './hooks/useDialogClose.js'; import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; +import { useMcpDialog } from './hooks/useMcpDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; import { requestConsentInteractive, @@ -493,6 +494,7 @@ export const AppContainer = (props: AppContainerProps) => { openAgentsManagerDialog, closeAgentsManagerDialog, } = useAgentsManagerDialog(); + const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog(); const slashCommandActions = useMemo( () => ({ @@ -515,6 +517,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openMcpDialog, openResumeDialog, }), [ @@ -530,6 +533,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openMcpDialog, openResumeDialog, ], ); @@ -1299,6 +1303,7 @@ export const AppContainer = (props: AppContainerProps) => { showIdeRestartPrompt || isSubagentCreateDialogOpen || isAgentsManagerDialogOpen || + isMcpDialogOpen || isApprovalModeDialogOpen || isResumeDialogOpen; @@ -1410,6 +1415,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // MCP dialog + isMcpDialogOpen, // Feedback dialog isFeedbackDialogOpen, }), @@ -1500,6 +1507,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // MCP dialog + isMcpDialogOpen, // Feedback dialog isFeedbackDialogOpen, ], @@ -1541,6 +1550,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // MCP dialog + closeMcpDialog, // Resume session dialog openResumeDialog, closeResumeDialog, @@ -1584,6 +1595,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // MCP dialog + closeMcpDialog, // Resume session dialog openResumeDialog, closeResumeDialog, diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 6f963397fc..f6fe3ca8de 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -12,13 +12,8 @@ import { MCPDiscoveryState, getMCPServerStatus, getMCPDiscoveryState, - DiscoveredMCPTool, } from '@qwen-code/qwen-code-core'; -import type { CallableTool } from '@google/genai'; -import { Type } from '@google/genai'; -import { MessageType } from '../types.js'; - vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const actual = await importOriginal(); @@ -37,23 +32,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }; }); -// Helper function to create a mock DiscoveredMCPTool -const createMockMCPTool = ( - name: string, - serverName: string, - description?: string, -) => - new DiscoveredMCPTool( - { - callTool: vi.fn(), - tool: vi.fn(), - } as unknown as CallableTool, - serverName, - name, - description || `Description for ${name}`, - { type: Type.OBJECT, properties: {} }, - ); - describe('mcpCommand', () => { let mockContext: ReturnType; let mockConfig: { @@ -70,7 +48,7 @@ describe('mcpCommand', () => { // Set up default mock environment vi.unstubAllEnvs(); - // Default mock implementations + // Default mock implementations - these are kept for auth subcommand tests vi.mocked(getMCPServerStatus).mockReturnValue(MCPServerStatus.CONNECTED); vi.mocked(getMCPDiscoveryState).mockReturnValue( MCPDiscoveryState.COMPLETED, @@ -98,7 +76,16 @@ describe('mcpCommand', () => { }); describe('basic functionality', () => { - it('should show an error if config is not available', async () => { + it('should open MCP management dialog by default', async () => { + const result = await mcpCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'mcp', + }); + }); + + it('should open MCP management dialog even if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { config: null, @@ -108,21 +95,19 @@ describe('mcpCommand', () => { const result = await mcpCommand.action!(contextWithoutConfig, ''); expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Config not loaded.', + type: 'dialog', + dialog: 'mcp', }); }); - it('should show an error if tool registry is not available', async () => { + it('should open MCP management dialog even if tool registry is not available', async () => { mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined); const result = await mcpCommand.action!(mockContext, ''); expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Could not retrieve tool registry.', + type: 'dialog', + dialog: 'mcp', }); }); }); @@ -138,73 +123,31 @@ describe('mcpCommand', () => { mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers); }); - it('should display configured MCP servers with status indicators and their tools', async () => { - // Setup getMCPServerStatus mock implementation - vi.mocked(getMCPServerStatus).mockImplementation((serverName) => { - if (serverName === 'server1') return MCPServerStatus.CONNECTED; - if (serverName === 'server2') return MCPServerStatus.CONNECTED; - return MCPServerStatus.DISCONNECTED; // server3 - }); + it('should open MCP management dialog regardless of server configuration', async () => { + const result = await mcpCommand.action!(mockContext, ''); - // Mock tools from each server using actual DiscoveredMCPTool instances - const mockServer1Tools = [ - createMockMCPTool('server1_tool1', 'server1'), - createMockMCPTool('server1_tool2', 'server1'), - ]; - const mockServer2Tools = [createMockMCPTool('server2_tool1', 'server2')]; - const mockServer3Tools = [createMockMCPTool('server3_tool1', 'server3')]; - - const allTools = [ - ...mockServer1Tools, - ...mockServer2Tools, - ...mockServer3Tools, - ]; - - mockConfig.getToolRegistry = vi.fn().mockReturnValue({ - getAllTools: vi.fn().mockReturnValue(allTools), + expect(result).toEqual({ + type: 'dialog', + dialog: 'mcp', }); - - await mcpCommand.action!(mockContext, ''); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.MCP_STATUS, - tools: allTools.map((tool) => ({ - serverName: tool.serverName, - name: tool.name, - description: tool.description, - schema: tool.schema, - })), - showTips: true, - }), - expect.any(Number), - ); }); - it('should display tool descriptions when desc argument is used', async () => { - await mcpCommand.action!(mockContext, 'desc'); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.MCP_STATUS, - showDescriptions: true, - showTips: false, - }), - expect.any(Number), - ); + it('should open MCP management dialog with desc argument', async () => { + const result = await mcpCommand.action!(mockContext, 'desc'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'mcp', + }); }); - it('should not display descriptions when nodesc argument is used', async () => { - await mcpCommand.action!(mockContext, 'nodesc'); - - expect(mockContext.ui.addItem).toHaveBeenCalledWith( - expect.objectContaining({ - type: MessageType.MCP_STATUS, - showDescriptions: false, - showTips: false, - }), - expect.any(Number), - ); + it('should open MCP management dialog with nodesc argument', async () => { + const result = await mcpCommand.action!(mockContext, 'nodesc'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'mcp', + }); }); }); }); diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index d8fec7177e..2a5100577a 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -6,24 +6,17 @@ import type { SlashCommand, - SlashCommandActionReturn, CommandContext, MessageActionReturn, + OpenDialogActionReturn, } from './types.js'; import { CommandKind } from './types.js'; -import type { DiscoveredMCPPrompt } from '@qwen-code/qwen-code-core'; import { - DiscoveredMCPTool, - getMCPDiscoveryState, - getMCPServerStatus, - MCPDiscoveryState, - MCPServerStatus, getErrorMessage, MCPOAuthTokenStorage, MCPOAuthProvider, } from '@qwen-code/qwen-code-core'; import { appEvents, AppEvent } from '../../utils/events.js'; -import { MessageType, type HistoryItemMcpStatus } from '../types.js'; import { t } from '../../i18n/index.js'; const authCommand: SlashCommand = { @@ -189,183 +182,30 @@ const authCommand: SlashCommand = { }, }; -const listCommand: SlashCommand = { - name: 'list', +const manageCommand: SlashCommand = { + name: 'manage', get description() { - return t('List configured MCP servers and tools'); + return t('Open MCP management dialog'); }, kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - const { config } = context.services; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - const toolRegistry = config.getToolRegistry(); - if (!toolRegistry) { - return { - type: 'message', - messageType: 'error', - content: t('Could not retrieve tool registry.'), - }; - } - - const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean); - - const hasDesc = - lowerCaseArgs.includes('desc') || lowerCaseArgs.includes('descriptions'); - const hasNodesc = - lowerCaseArgs.includes('nodesc') || - lowerCaseArgs.includes('nodescriptions'); - const showSchema = lowerCaseArgs.includes('schema'); - - const showDescriptions = !hasNodesc && (hasDesc || showSchema); - const showTips = lowerCaseArgs.length === 0; - - const mcpServers = config.getMcpServers() || {}; - const serverNames = Object.keys(mcpServers); - const blockedMcpServers = config.getBlockedMcpServers() || []; - - const connectingServers = serverNames.filter( - (name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING, - ); - const discoveryState = getMCPDiscoveryState(); - const discoveryInProgress = - discoveryState === MCPDiscoveryState.IN_PROGRESS || - connectingServers.length > 0; - - const allTools = toolRegistry.getAllTools(); - const mcpTools = allTools.filter( - (tool) => tool instanceof DiscoveredMCPTool, - ) as DiscoveredMCPTool[]; - - const promptRegistry = await config.getPromptRegistry(); - const mcpPrompts = promptRegistry - .getAllPrompts() - .filter( - (prompt) => - 'serverName' in prompt && - serverNames.includes(prompt.serverName as string), - ) as DiscoveredMCPPrompt[]; - - const authStatus: HistoryItemMcpStatus['authStatus'] = {}; - const tokenStorage = new MCPOAuthTokenStorage(); - for (const serverName of serverNames) { - const server = mcpServers[serverName]; - if (server.oauth?.enabled) { - const creds = await tokenStorage.getCredentials(serverName); - if (creds) { - if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) { - authStatus[serverName] = 'expired'; - } else { - authStatus[serverName] = 'authenticated'; - } - } else { - authStatus[serverName] = 'unauthenticated'; - } - } else { - authStatus[serverName] = 'not-configured'; - } - } - - const mcpStatusItem: HistoryItemMcpStatus = { - type: MessageType.MCP_STATUS, - servers: mcpServers, - tools: mcpTools.map((tool) => ({ - serverName: tool.serverName, - name: tool.name, - description: tool.description, - schema: tool.schema, - })), - prompts: mcpPrompts.map((prompt) => ({ - serverName: prompt.serverName as string, - name: prompt.name, - description: prompt.description, - })), - authStatus, - blockedServers: blockedMcpServers, - discoveryInProgress, - connectingServers, - showDescriptions, - showSchema, - showTips, - }; - - context.ui.addItem(mcpStatusItem, Date.now()); - }, -}; - -const refreshCommand: SlashCommand = { - name: 'refresh', - get description() { - return t('Restarts MCP servers.'); - }, - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - ): Promise => { - const { config } = context.services; - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - const toolRegistry = config.getToolRegistry(); - if (!toolRegistry) { - return { - type: 'message', - messageType: 'error', - content: t('Could not retrieve tool registry.'), - }; - } - - context.ui.addItem( - { - type: 'info', - text: t('Restarting MCP servers...'), - }, - Date.now(), - ); - - await toolRegistry.restartMcpServers(); - - // Update the client with the new tools - const geminiClient = config.getGeminiClient(); - if (geminiClient) { - await geminiClient.setTools(); - } - - // Reload the slash commands to reflect the changes. - context.ui.reloadCommands(); - - return listCommand.action!(context, ''); - }, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'mcp', + }), }; export const mcpCommand: SlashCommand = { name: 'mcp', get description() { return t( - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers', + 'Open MCP management dialog, or authenticate with OAuth-enabled servers', ); }, kind: CommandKind.BUILT_IN, - subCommands: [listCommand, authCommand, refreshCommand], - // Default action when no subcommand is provided - action: async ( - context: CommandContext, - args: string, - ): Promise => - // If no subcommand, run the list command - listCommand.action!(context, args), + subCommands: [manageCommand, authCommand], + // Default action when no subcommand is provided - open dialog + action: async (): Promise => ({ + type: 'dialog', + dialog: 'mcp', + }), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 90330e9884..b84f38b13b 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -148,7 +148,8 @@ export interface OpenDialogActionReturn { | 'subagent_list' | 'permissions' | 'approval-mode' - | 'resume'; + | 'resume' + | 'mcp'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index c79e911195..764d889508 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -34,6 +34,7 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; +import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; interface DialogManagerProps { @@ -292,6 +293,10 @@ export const DialogManager = ({ ); } + if (uiState.isMcpDialogOpen) { + return ; + } + if (uiState.isResumeDialogOpen) { return ( = ({ + onClose, +}) => { + const config = useConfig(); + + const [servers, setServers] = useState([]); + const [selectedServerIndex, setSelectedServerIndex] = useState(-1); + const [selectedTool, setSelectedTool] = useState( + null, + ); + const [navigationStack, setNavigationStack] = useState([ + MCP_MANAGEMENT_STEPS.SERVER_LIST, + ]); + const [isLoading, setIsLoading] = useState(true); + + // Load MCP server data - extracted to a separate function for reuse + const fetchServerData = useCallback(async (): Promise< + MCPServerDisplayInfo[] + > => { + if (!config) return []; + + const mcpServers = config.getMcpServers() || {}; + const toolRegistry = config.getToolRegistry(); + const promptRegistry = config.getPromptRegistry(); + + // Get settings to determine the scope of each server + const settings = loadSettings(); + const userSettings = settings.forScope(SettingScope.User).settings; + const workspaceSettings = settings.forScope( + SettingScope.Workspace, + ).settings; + + const serverInfos: MCPServerDisplayInfo[] = []; + + for (const [name, serverConfig] of Object.entries(mcpServers) as Array< + [string, MCPServerConfig] + >) { + const status = getMCPServerStatus(name); + + // Get tools for this server + const allTools: AnyDeclarativeTool[] = toolRegistry?.getAllTools() || []; + const serverTools = allTools.filter( + (t): t is DiscoveredMCPTool => + t instanceof DiscoveredMCPTool && t.serverName === name, + ); + + // Get prompts for this server + const allPrompts: DiscoveredMCPPrompt[] = + promptRegistry?.getAllPrompts() || []; + const serverPrompts = allPrompts.filter( + (p) => 'serverName' in p && p.serverName === name, + ); + + // Determine source type + let source: 'user' | 'project' | 'extension' = 'user'; + if (serverConfig.extensionName) { + source = 'extension'; + } + + // Determine the scope of the configuration + let scope: 'user' | 'workspace' | 'extension' = 'user'; + if (serverConfig.extensionName) { + scope = 'extension'; + } else if (workspaceSettings.mcpServers?.[name]) { + scope = 'workspace'; + } else if (userSettings.mcpServers?.[name]) { + scope = 'user'; + } + + // Use config.isMcpServerDisabled() to check if server is disabled + const isDisabled = config.isMcpServerDisabled(name); + + // Count invalid tools (missing name or description) + const invalidToolCount = serverTools.filter( + (t) => !t.name || !t.description, + ).length; + + serverInfos.push({ + name, + status, + source, + scope, + config: serverConfig, + toolCount: serverTools.length, + invalidToolCount, + promptCount: serverPrompts.length, + isDisabled, + }); + } + + return serverInfos; + }, [config]); + + // Load MCP server data on initial render + useEffect(() => { + const loadServers = async () => { + setIsLoading(true); + try { + const serverInfos = await fetchServerData(); + setServers(serverInfos); + } catch (error) { + debugLogger.error('Error loading MCP servers:', error); + } finally { + setIsLoading(false); + } + }; + + loadServers(); + }, [fetchServerData]); + + // Selected server + const selectedServer = useMemo(() => { + if (selectedServerIndex >= 0 && selectedServerIndex < servers.length) { + return servers[selectedServerIndex]; + } + return null; + }, [servers, selectedServerIndex]); + + // Current step + const getCurrentStep = useCallback( + () => + navigationStack[navigationStack.length - 1] || + MCP_MANAGEMENT_STEPS.SERVER_LIST, + [navigationStack], + ); + + // Navigation handlers + const handleNavigateToStep = useCallback((step: string) => { + setNavigationStack((prev) => [...prev, step]); + }, []); + + const handleNavigateBack = useCallback(() => { + setNavigationStack((prev) => { + if (prev.length <= 1) return prev; + return prev.slice(0, -1); + }); + }, []); + + // Select server + const handleSelectServer = useCallback( + (index: number) => { + setSelectedServerIndex(index); + handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL); + }, + [handleNavigateToStep], + ); + + // Get server tool list + const getServerTools = useCallback((): MCPToolDisplayInfo[] => { + if (!config || !selectedServer) return []; + + const toolRegistry = config.getToolRegistry(); + if (!toolRegistry) return []; + + const allTools: AnyDeclarativeTool[] = toolRegistry.getAllTools(); + const mcpTools: DiscoveredMCPTool[] = []; + for (const tool of allTools) { + if ( + tool instanceof DiscoveredMCPTool && + tool.serverName === selectedServer.name + ) { + mcpTools.push(tool); + } + } + return mcpTools.map((tool) => { + // Check if tool is valid (has both name and description required by LLM) + const isValid = isToolValid(tool.name, tool.description); + + let invalidReason: string | undefined; + if (!isValid) { + const reasons = getToolInvalidReasons(tool.name, tool.description); + invalidReason = reasons.map((r) => t(r)).join(', '); + } + + return { + name: tool.name || t('(unnamed)'), + description: tool.description, + serverName: tool.serverName, + schema: tool.parameterSchema as object | undefined, + annotations: tool.annotations, + isValid, + invalidReason, + }; + }); + }, [config, selectedServer]); + + // View tool list + const handleViewTools = useCallback(() => { + handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST); + }, [handleNavigateToStep]); + + // Select tool + const handleSelectTool = useCallback( + (tool: MCPToolDisplayInfo) => { + setSelectedTool(tool); + handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL); + }, + [handleNavigateToStep], + ); + + // Reload server data - uses the extracted fetchServerData function + const reloadServers = useCallback(async () => { + setIsLoading(true); + try { + const serverInfos = await fetchServerData(); + setServers(serverInfos); + } catch (error) { + debugLogger.error('Error reloading MCP servers:', error); + } finally { + setIsLoading(false); + } + }, [fetchServerData]); + + // Reconnect server + const handleReconnect = useCallback(async () => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(selectedServer.name); + } + // Reload server data to update status + await reloadServers(); + } catch (error) { + debugLogger.error( + `Error reconnecting to server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } + }, [config, selectedServer, reloadServers]); + + // Enable server + const handleEnableServer = useCallback(async () => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + + const server = selectedServer; + const settings = loadSettings(); + + // Remove from user and workspace exclusion lists + for (const scope of [SettingScope.User, SettingScope.Workspace]) { + const scopeSettings = settings.forScope(scope).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + + if (currentExcluded.includes(server.name)) { + const newExcluded = currentExcluded.filter( + (name: string) => name !== server.name, + ); + settings.setValue(scope, 'mcp.excluded', newExcluded); + } + } + + // Update runtime config exclusion list + const currentExcluded = config.getExcludedMcpServers() || []; + const newExcluded = currentExcluded.filter( + (name: string) => name !== server.name, + ); + config.setExcludedMcpServers(newExcluded); + + // Rediscover tools for this server + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(server.name); + } + + // Reload server data + await reloadServers(); + } catch (error) { + debugLogger.error( + `Error enabling server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } + }, [config, selectedServer, reloadServers]); + + // Handle disable/enable action + const handleDisable = useCallback(() => { + if (!selectedServer) return; + + // If server is already disabled, enable it directly + if (selectedServer.isDisabled) { + void handleEnableServer(); + } else { + // Otherwise navigate to disable scope selection + handleNavigateToStep(MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT); + } + }, [selectedServer, handleEnableServer, handleNavigateToStep]); + + // Execute disable after selecting scope + const handleSelectDisableScope = useCallback( + async (scope: 'user' | 'workspace') => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + + const server = selectedServer; + const settings = loadSettings(); + + // Get current exclusion list + const scopeSettings = settings.forScope( + scope === 'user' ? SettingScope.User : SettingScope.Workspace, + ).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + + // If server is not in exclusion list, add it + if (!currentExcluded.includes(server.name)) { + const newExcluded = [...currentExcluded, server.name]; + settings.setValue( + scope === 'user' ? SettingScope.User : SettingScope.Workspace, + 'mcp.excluded', + newExcluded, + ); + } + + // Use new disableMcpServer method to disable server + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.disableMcpServer(server.name); + } + + // Reload server list + await reloadServers(); + + // Return to server detail page + handleNavigateBack(); + } catch (error) { + debugLogger.error( + `Error disabling server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } + }, + [config, selectedServer, handleNavigateBack, reloadServers], + ); + + // Render step header + const renderStepHeader = useCallback(() => { + const currentStep = getCurrentStep(); + let headerText = ''; + + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_LIST: + headerText = t('Manage MCP servers'); + break; + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + headerText = selectedServer?.name || t('Server Detail'); + break; + case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + headerText = t('Disable Server'); + break; + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + headerText = t('Tools'); + break; + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + headerText = selectedTool?.name || t('Tool Detail'); + break; + default: + headerText = t('MCP Management'); + } + + return ( + + + {headerText} + + + ); + }, [getCurrentStep, selectedServer, selectedTool]); + + // Render step content + const renderStepContent = useCallback(() => { + if (isLoading) { + return {t('Loading...')}; + } + + const currentStep = getCurrentStep(); + + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_LIST: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + return ( + + ); + + default: + return ( + + {t('Unknown step')} + + ); + } + }, [ + isLoading, + getCurrentStep, + servers, + selectedServer, + selectedTool, + handleSelectServer, + handleViewTools, + handleReconnect, + handleDisable, + handleNavigateBack, + handleSelectTool, + handleSelectDisableScope, + getServerTools, + ]); + + // Render step footer + const renderStepFooter = useCallback(() => { + const currentStep = getCurrentStep(); + let footerText = ''; + + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_LIST: + if (servers.length === 0) { + footerText = t('Esc to close'); + } else { + footerText = t('↑↓ to navigate · Enter to select · Esc to close'); + } + break; + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + footerText = t('↑↓ to navigate · Enter to select · Esc to back'); + break; + case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + footerText = t('↑↓ to navigate · Enter to confirm · Esc to back'); + break; + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + footerText = t('↑↓ to navigate · Enter to select · Esc to back'); + break; + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + footerText = t('Esc to back'); + break; + default: + footerText = t('Esc to close'); + } + + return ( + + {footerText} + + ); + }, [getCurrentStep, servers.length]); + + // ESC key handler - only close dialog, child components handle back navigation to avoid duplicate triggers + useKeypress( + (key) => { + if ( + key.name === 'escape' && + getCurrentStep() === MCP_MANAGEMENT_STEPS.SERVER_LIST + ) { + onClose(); + } + }, + { isActive: true }, + ); + + return ( + + + {renderStepHeader()} + {renderStepContent()} + {renderStepFooter()} + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/constants.ts b/packages/cli/src/ui/components/mcp/constants.ts new file mode 100644 index 0000000000..cfdc2691ff --- /dev/null +++ b/packages/cli/src/ui/components/mcp/constants.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * MCP管理相关常量 + */ + +/** + * 最大显示工具数量 + */ +export const MAX_DISPLAY_TOOLS = 10; + +/** + * 最大显示prompt数量 + */ +export const MAX_DISPLAY_PROMPTS = 10; + +/** + * 日志列表可视区域最大显示数量 + */ +export const VISIBLE_LOGS_COUNT = 15; + +/** + * 工具列表可视区域最大显示数量 + */ +export const VISIBLE_TOOLS_COUNT = 10; + +/** + * 分组显示名称映射 + */ +export const SOURCE_DISPLAY_NAMES: Record = { + user: 'User MCPs', + project: 'Project MCPs', + extension: 'Extension MCPs', +}; + +/** + * 状态显示文本 + */ +export const STATUS_TEXT: Record = { + connected: 'connected', + connecting: 'connecting', + disconnected: 'failed', +}; diff --git a/packages/cli/src/ui/components/mcp/index.ts b/packages/cli/src/ui/components/mcp/index.ts new file mode 100644 index 0000000000..01ebfee8f9 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/index.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Main Dialog +export { MCPManagementDialog } from './MCPManagementDialog.js'; + +// Steps +export { ServerListStep } from './steps/ServerListStep.js'; +export { ServerDetailStep } from './steps/ServerDetailStep.js'; +export { ToolListStep } from './steps/ToolListStep.js'; +export { ToolDetailStep } from './steps/ToolDetailStep.js'; + +// Types +export type { + MCPManagementDialogProps, + MCPServerDisplayInfo, + MCPToolDisplayInfo, + MCPPromptDisplayInfo, + ServerListStepProps, + ServerDetailStepProps, + ToolListStepProps, + ToolDetailStepProps, + MCPManagementStep, +} from './types.js'; + +// Constants +export { MCP_MANAGEMENT_STEPS } from './types.js'; diff --git a/packages/cli/src/ui/components/mcp/steps/DisableScopeSelectStep.tsx b/packages/cli/src/ui/components/mcp/steps/DisableScopeSelectStep.tsx new file mode 100644 index 0000000000..3c97ccfd15 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/DisableScopeSelectStep.tsx @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { t } from '../../../../i18n/index.js'; +import type { DisableScopeSelectStepProps } from '../types.js'; + +export const DisableScopeSelectStep: React.FC = ({ + server, + onSelectScope, + onBack, +}) => { + const [selectedScope, setSelectedScope] = useState<'user' | 'workspace'>( + 'user', + ); + + const scopes = [ + { + key: 'user', + get label() { + return t('User Settings (global)'); + }, + value: 'user' as const, + }, + { + key: 'workspace', + get label() { + return t('Workspace Settings (project-specific)'); + }, + value: 'workspace' as const, + }, + ]; + + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } else if (key.name === 'return') { + onSelectScope(selectedScope); + } + }, + { isActive: true }, + ); + + if (!server) { + return ( + + {t('No server selected')} + + ); + } + + return ( + + + + {t('Disable server:')} {server.name} + + + + {t('Select where to add the server to the exclude list:')} + + + + + + + items={scopes} + onHighlight={(value: 'user' | 'workspace') => setSelectedScope(value)} + onSelect={(value: 'user' | 'workspace') => onSelectScope(value)} + /> + + + + + {t('Press Enter to confirm, Esc to cancel')} + + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx new file mode 100644 index 0000000000..07b8da439b --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { t } from '../../../../i18n/index.js'; +import type { ServerDetailStepProps } from '../types.js'; +import { + getStatusColor, + getStatusIcon, + formatServerCommand, +} from '../utils.js'; + +// 标签列宽度 +const LABEL_WIDTH = 15; + +type ServerAction = 'view-tools' | 'reconnect' | 'toggle-disable'; + +export const ServerDetailStep: React.FC = ({ + server, + onViewTools, + onReconnect, + onDisable, + onBack, +}) => { + const [selectedAction, setSelectedAction] = + useState('view-tools'); + + const statusColor = server ? getStatusColor(server.status) : 'gray'; + + const actions = [ + { + key: 'view-tools', + get label() { + return t('View tools'); + }, + value: 'view-tools' as const, + }, + { + key: 'reconnect', + get label() { + return t('Reconnect'); + }, + value: 'reconnect' as const, + }, + { + key: 'toggle-disable', + get label() { + return server?.isDisabled ? t('Enable') : t('Disable'); + }, + value: 'toggle-disable' as const, + }, + ]; + + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } else if (key.name === 'return') { + switch (selectedAction) { + case 'view-tools': + onViewTools(); + break; + case 'reconnect': + onReconnect?.(); + break; + case 'toggle-disable': + onDisable?.(); + break; + default: + break; + } + } + }, + { isActive: true }, + ); + + if (!server) { + return ( + + {t('No server selected')} + + ); + } + + return ( + + {/* 服务器详情 */} + + + + {t('Status:')} + + + + {getStatusIcon(server.status)} {t(server.status)} + {server.isDisabled && ( + {t('(disabled)')} + )} + + + + + + + {t('Source:')} + + + + {server.scope === 'user' + ? t('User Settings') + : server.scope === 'workspace' + ? t('Workspace Settings') + : t('Extension')} + + + + + + + {t('Command:')} + + + {formatServerCommand(server)} + + + + {server.config.cwd && ( + + + {t('Working Directory:')} + + + {server.config.cwd} + + + )} + + + + {t('Capabilities:')} + + + + {server.toolCount > 0 ? t('tools') : ''} + {server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''} + {server.promptCount > 0 ? t('prompts') : ''} + + + + + + + {t('Tools:')} + + + + {server.toolCount}{' '} + {server.toolCount === 1 ? t('tool') : t('tools')} + {!!server.invalidToolCount && server.invalidToolCount > 0 && ( + + {' '} + ({server.invalidToolCount}{' '} + {server.invalidToolCount === 1 ? t('invalid') : t('invalid')}) + + )} + + + + + {server.errorMessage && ( + + + {t('Error:')} + + + + {server.errorMessage} + + + + )} + + + {/* 操作列表 */} + + + items={actions} + onHighlight={(value: ServerAction) => setSelectedAction(value)} + onSelect={(value: ServerAction) => { + switch (value) { + case 'view-tools': + onViewTools(); + break; + case 'reconnect': + onReconnect?.(); + break; + case 'toggle-disable': + onDisable?.(); + break; + default: + break; + } + }} + /> + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx new file mode 100644 index 0000000000..35cff6708c --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; +import type { ServerListStepProps, MCPServerDisplayInfo } from '../types.js'; +import { + groupServersBySource, + getStatusIcon, + getStatusColor, +} from '../utils.js'; + +export const ServerListStep: React.FC = ({ + servers, + onSelect, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const groupedServers = useMemo( + () => groupServersBySource(servers), + [servers], + ); + + // 动态计算服务器名称列的最大宽度(基于实际内容) + const serverNameWidth = useMemo(() => { + if (servers.length === 0) return 20; + const maxLength = Math.max(...servers.map((s) => s.name.length)); + // 最小 20,最大 35,留一些余量 + return Math.min(Math.max(maxLength + 2, 20), 35); + }, [servers]); + + // 计算扁平化的服务器列表用于导航 + const flatServers = useMemo(() => { + const result: MCPServerDisplayInfo[] = []; + for (const group of groupedServers) { + result.push(...group.servers); + } + return result; + }, [groupedServers]); + + // 键盘导航 + useKeypress( + (key) => { + if (key.name === 'up') { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.name === 'down') { + setSelectedIndex((prev) => Math.min(flatServers.length - 1, prev + 1)); + } else if (key.name === 'return') { + onSelect(selectedIndex); + } + }, + { isActive: true }, + ); + + if (servers.length === 0) { + return ( + + + {t('No MCP servers configured.')} + + + {t('Add MCP servers to your settings to get started.')} + + + ); + } + + // 计算当前选中项在分组中的位置 + const getSelectionPosition = (globalIndex: number) => { + let currentIndex = 0; + for (const group of groupedServers) { + if (globalIndex < currentIndex + group.servers.length) { + return { + groupIndex: groupedServers.indexOf(group), + itemIndex: globalIndex - currentIndex, + }; + } + currentIndex += group.servers.length; + } + return { groupIndex: 0, itemIndex: 0 }; + }; + + const currentPosition = getSelectionPosition(selectedIndex); + + return ( + + {/* 服务器统计 */} + + + {servers.length} {servers.length === 1 ? t('server') : t('servers')} + + + + {/* 分组服务器列表 */} + {groupedServers.map((group, groupIndex) => ( + + + {group.displayName} + {group.servers[0]?.configPath && ( + + {' '} + ({group.servers[0].configPath}) + + )} + + + {group.servers.map((server, itemIndex) => { + const isSelected = + groupIndex === currentPosition.groupIndex && + itemIndex === currentPosition.itemIndex; + const statusColor = getStatusColor(server.status); + + return ( + + + + {isSelected ? '❯' : ' '} + + + {/* 服务器名称 - 固定宽度 */} + + + {server.name} + + + · + {/* 状态图标和文本 */} + + {getStatusIcon(server.status)} {t(server.status)} + + {/* 显示 Scope 和禁用状态 */} + [{server.scope}] + {server.isDisabled && ( + {t('(disabled)')} + )} + {/* 显示无效工具警告 */} + {!!server.invalidToolCount && server.invalidToolCount > 0 && ( + + {' '} + {t('{{count}} invalid tools', { + count: String(server.invalidToolCount), + })} + + )} + + ); + })} + + + ))} + + {/* 提示信息 */} + {servers.some((s) => s.status === 'disconnected') && ( + + + ※ {t('Run qwen --debug to see error logs')} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx new file mode 100644 index 0000000000..0bf32b8607 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx @@ -0,0 +1,217 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; +import type { ToolDetailStepProps } from '../types.js'; + +/** + * 截断过长的字符串 + */ +const truncate = (str: string, maxLen: number = 50): string => { + if (str.length <= maxLen) return str; + return str.substring(0, maxLen - 3) + '...'; +}; + +/** + * 渲染单个参数 + */ +const renderParameter = ( + name: string, + param: Record, + isRequired: boolean, +): React.ReactNode => { + const type = (param['type'] as string) || 'any'; + const description = (param['description'] as string) || ''; + const defaultValue = param['default']; + const enumValues = param['enum'] as string[] | undefined; + + return ( + + + • {name} + {isRequired && ( + ({t('required')}) + )} + + + {t('Type')}: + {type} + + {description && ( + + + {truncate(description, 80)} + + + )} + {enumValues && enumValues.length > 0 && ( + + + {t('Enum')}: {enumValues.join(', ')} + + + )} + {defaultValue !== undefined && ( + + + {t('Default')}:{' '} + {typeof defaultValue === 'string' + ? `"${truncate(defaultValue, 30)}"` + : String(defaultValue)} + + + )} + + ); +}; + +/** + * 渲染参数列表 + */ +const ParametersList: React.FC<{ + properties: Record; + required: string[]; +}> = ({ properties, required }) => { + const requiredSet = new Set(required); + + return ( + + {t('Parameters')}: + + {Object.entries(properties).map(([name, param]) => + renderParameter( + name, + param as Record, + requiredSet.has(name), + ), + )} + + + ); +}; + +/** + * 提取并展示schema的关键信息,使用类似示例的格式 + */ +const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => { + const obj = schema as Record; + const properties = obj['properties'] as Record | undefined; + const required = (obj['required'] as string[]) || []; + + return ( + + {/* 参数列表 */} + {properties && Object.keys(properties).length > 0 && ( + + )} + + ); +}; + +export const ToolDetailStep: React.FC = ({ + tool, + onBack, +}) => { + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } + }, + { isActive: true }, + ); + + if (!tool) { + return ( + + {t('No tool selected')} + + ); + } + + return ( + + {/* 无效工具警告 */} + {!tool.isValid && ( + + + {t('Warning: This tool cannot be called by the LLM')} + + + {t('Reason')}: {tool.invalidReason || t('unknown')} + + + {t( + 'Tools must have both name and description to be used by the LLM.', + )} + + + )} + + {/* 工具描述 */} + {tool.description && ( + + {tool.description} + + )} + + {/* 工具注解 */} + {tool.annotations && ( + + {t('Annotations')}: + + {tool.annotations.title && ( + + • {t('Title')}: {tool.annotations.title} + + )} + {tool.annotations.readOnlyHint !== undefined && ( + + • {t('Read Only')}:{' '} + {tool.annotations.readOnlyHint ? t('Yes') : t('No')} + + )} + {tool.annotations.destructiveHint !== undefined && ( + + • {t('Destructive')}:{' '} + {tool.annotations.destructiveHint ? t('Yes') : t('No')} + + )} + {tool.annotations.idempotentHint !== undefined && ( + + • {t('Idempotent')}:{' '} + {tool.annotations.idempotentHint ? t('Yes') : t('No')} + + )} + {tool.annotations.openWorldHint !== undefined && ( + + • {t('Open World')}:{' '} + {tool.annotations.openWorldHint ? t('Yes') : t('No')} + + )} + + + )} + + {/* Schema */} + {tool.schema && ( + + + + )} + + {/* 所属服务器 */} + + + {t('Server')}: {tool.serverName} + + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx new file mode 100644 index 0000000000..de9f4fa6c4 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; +import type { ToolListStepProps, MCPToolDisplayInfo } from '../types.js'; +import { VISIBLE_TOOLS_COUNT } from '../constants.js'; + +export const ToolListStep: React.FC = ({ + tools, + serverName, + onSelect, + onBack, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + // 动态计算工具名称列的最大宽度(基于实际内容) + const toolNameWidth = useMemo(() => { + if (tools.length === 0) return 30; + const maxLength = Math.max(...tools.map((t) => t.name.length)); + // 最小 30,最大 50,留一些余量 + return Math.min(Math.max(maxLength + 2, 30), 50); + }, [tools]); + + // 计算可视区域的起始索引(滚动窗口) + const scrollOffset = useMemo(() => { + if (tools.length <= VISIBLE_TOOLS_COUNT) { + return 0; + } + // 确保选中项在可视区域内 + if (selectedIndex < VISIBLE_TOOLS_COUNT - 1) { + return 0; + } + return Math.min( + selectedIndex - VISIBLE_TOOLS_COUNT + 1, + tools.length - VISIBLE_TOOLS_COUNT, + ); + }, [selectedIndex, tools.length]); + + // 当前可视的工具列表 + const displayTools = useMemo( + () => tools.slice(scrollOffset, scrollOffset + VISIBLE_TOOLS_COUNT), + [tools, scrollOffset], + ); + + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } else if (key.name === 'up') { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + } else if (key.name === 'down') { + setSelectedIndex((prev) => Math.min(tools.length - 1, prev + 1)); + } else if (key.name === 'return') { + if (tools[selectedIndex]) { + onSelect(tools[selectedIndex]); + } + } + }, + { isActive: true }, + ); + + if (tools.length === 0) { + return ( + + + {t('No tools available for this server.')} + + + ); + } + + const getToolAnnotations = (tool: MCPToolDisplayInfo): string => { + const hints: string[] = []; + if (tool.annotations?.destructiveHint) hints.push(t('destructive')); + if (tool.annotations?.readOnlyHint) hints.push(t('read-only')); + if (tool.annotations?.openWorldHint) hints.push(t('open-world')); + if (tool.annotations?.idempotentHint) hints.push(t('idempotent')); + return hints.join(', '); + }; + + return ( + + {/* 标题 */} + + {t('Tools for {{name}}', { name: serverName })} + + {' '} + ({tools.length} {tools.length === 1 ? t('tool') : t('tools')}) + + + + {/* 工具列表 */} + + {displayTools.map((tool, index) => { + const actualIndex = scrollOffset + index; + const isSelected = actualIndex === selectedIndex; + const annotations = getToolAnnotations(tool); + + return ( + + {/* 选择器和序号 */} + + + {isSelected ? '❯' : ' '} + + {actualIndex + 1}. + + {/* 工具名称 - 固定宽度 */} + + + {tool.name} + + + {/* 显示无效工具警告 */} + {!tool.isValid && ( + + {t('invalid: {{reason}}', { + reason: tool.invalidReason || t('unknown'), + })} + + )} + {annotations && tool.isValid && ( + {annotations} + )} + + ); + })} + + + {/* 滚动提示 */} + {tools.length > VISIBLE_TOOLS_COUNT && ( + + + {scrollOffset > 0 ? '↑ ' : ' '} + {t('{{current}}/{{total}}', { + current: (selectedIndex + 1).toString(), + total: tools.length.toString(), + })} + {scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts new file mode 100644 index 0000000000..1133592bbf --- /dev/null +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + MCPServerConfig, + MCPServerStatus, +} from '@qwen-code/qwen-code-core'; + +/** + * MCP管理步骤定义 + */ +export const MCP_MANAGEMENT_STEPS = { + SERVER_LIST: 'server-list', + SERVER_DETAIL: 'server-detail', + DISABLE_SCOPE_SELECT: 'disable-scope-select', + TOOL_LIST: 'tool-list', + TOOL_DETAIL: 'tool-detail', +} as const; + +export type MCPManagementStep = + (typeof MCP_MANAGEMENT_STEPS)[keyof typeof MCP_MANAGEMENT_STEPS]; + +/** + * MCP服务器显示信息 + */ +export interface MCPServerDisplayInfo { + /** 服务器名称 */ + name: string; + /** 连接状态 */ + status: MCPServerStatus; + /** 来源类型 */ + source: 'user' | 'project' | 'extension'; + /** 配置所在的 scope */ + scope: 'user' | 'workspace' | 'extension'; + /** 配置文件路径 */ + configPath?: string; + /** 服务器配置 */ + config: MCPServerConfig; + /** 工具数量 */ + toolCount: number; + /** 无效工具数量(缺少name或description) */ + invalidToolCount?: number; + /** Prompt数量 */ + promptCount: number; + /** 错误信息 */ + errorMessage?: string; + /** 是否被禁用(在排除列表中) */ + isDisabled: boolean; +} + +/** + * MCP工具显示信息 + */ +export interface MCPToolDisplayInfo { + /** 工具名称 */ + name: string; + /** 工具描述 */ + description?: string; + /** 所属服务器 */ + serverName: string; + /** 工具schema */ + schema?: object; + /** 工具注解 */ + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; + /** 工具是否有效(有name和description才能被LLM调用) */ + isValid: boolean; + /** 无效原因(当isValid为false时) */ + invalidReason?: string; +} + +/** + * MCP Prompt显示信息 + */ +export interface MCPPromptDisplayInfo { + /** Prompt名称 */ + name: string; + /** Prompt描述 */ + description?: string; + /** 所属服务器 */ + serverName: string; + /** 参数定义 */ + arguments?: Array<{ + name: string; + description?: string; + required?: boolean; + }>; +} + +/** + * 分组后的服务器列表 + */ +export interface GroupedServers { + /** 来源标识 */ + source: string; + /** 来源显示名称 */ + displayName: string; + /** 配置文件路径 */ + configPath?: string; + /** 服务器列表 */ + servers: MCPServerDisplayInfo[]; +} + +/** + * ServerListStep组件属性 + */ +export interface ServerListStepProps { + /** 服务器列表 */ + servers: MCPServerDisplayInfo[]; + /** 选择回调 */ + onSelect: (index: number) => void; +} + +/** + * ServerDetailStep组件属性 + */ +export interface ServerDetailStepProps { + /** 选中的服务器 */ + server: MCPServerDisplayInfo | null; + /** 查看工具列表回调 */ + onViewTools: () => void; + /** 重新连接回调 */ + onReconnect?: () => void; + /** 禁用服务器回调 */ + onDisable?: () => void; + /** 返回回调 */ + onBack: () => void; +} + +/** + * DisableScopeSelectStep组件属性 + */ +export interface DisableScopeSelectStepProps { + /** 选中的服务器 */ + server: MCPServerDisplayInfo | null; + /** 选择 scope 回调 */ + onSelectScope: (scope: 'user' | 'workspace') => void; + /** 返回回调 */ + onBack: () => void; +} + +/** + * ToolListStep组件属性 + */ +export interface ToolListStepProps { + /** 工具列表 */ + tools: MCPToolDisplayInfo[]; + /** 服务器名称 */ + serverName: string; + /** 选择回调 */ + onSelect: (tool: MCPToolDisplayInfo) => void; + /** 返回回调 */ + onBack: () => void; +} + +/** + * ToolDetailStep组件属性 + */ +export interface ToolDetailStepProps { + /** 工具信息 */ + tool: MCPToolDisplayInfo | null; + /** 返回回调 */ + onBack: () => void; +} + +/** + * MCP管理对话框属性 + */ +export interface MCPManagementDialogProps { + /** 关闭回调 */ + onClose: () => void; +} diff --git a/packages/cli/src/ui/components/mcp/utils.test.ts b/packages/cli/src/ui/components/mcp/utils.test.ts new file mode 100644 index 0000000000..3b058ba554 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/utils.test.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + groupServersBySource, + getStatusColor, + getStatusIcon, + truncateText, + formatServerCommand, + isToolValid, + getToolInvalidReasons, +} from './utils.js'; +import type { MCPServerDisplayInfo } from './types.js'; +import { MCPServerStatus } from '@qwen-code/qwen-code-core'; + +describe('MCP utils', () => { + describe('groupServersBySource', () => { + it('should group servers by source', () => { + const servers: MCPServerDisplayInfo[] = [ + { + name: 'server1', + status: MCPServerStatus.CONNECTED, + source: 'user', + scope: 'user', + config: { command: 'cmd1' }, + toolCount: 1, + promptCount: 0, + isDisabled: false, + }, + { + name: 'server2', + status: MCPServerStatus.CONNECTED, + source: 'extension', + scope: 'extension', + config: { command: 'cmd2' }, + toolCount: 2, + promptCount: 0, + isDisabled: false, + }, + ]; + + const result = groupServersBySource(servers); + + expect(result).toHaveLength(2); + expect(result[0].source).toBe('user'); + expect(result[0].servers).toHaveLength(1); + expect(result[1].source).toBe('extension'); + }); + }); + + describe('getStatusColor', () => { + it('should return correct colors for each status', () => { + expect(getStatusColor(MCPServerStatus.CONNECTED)).toBe('green'); + expect(getStatusColor(MCPServerStatus.CONNECTING)).toBe('yellow'); + expect(getStatusColor(MCPServerStatus.DISCONNECTED)).toBe('red'); + expect(getStatusColor('unknown' as MCPServerStatus)).toBe('gray'); + }); + }); + + describe('getStatusIcon', () => { + it('should return correct icons for each status', () => { + expect(getStatusIcon(MCPServerStatus.CONNECTED)).toBe('✓'); + expect(getStatusIcon(MCPServerStatus.CONNECTING)).toBe('…'); + expect(getStatusIcon(MCPServerStatus.DISCONNECTED)).toBe('✗'); + expect(getStatusIcon('unknown' as MCPServerStatus)).toBe('?'); + }); + }); + + describe('truncateText', () => { + it('should truncate text longer than maxLength', () => { + expect(truncateText('hello world', 8)).toBe('hello...'); + }); + + it('should not truncate text shorter than maxLength', () => { + expect(truncateText('hello', 10)).toBe('hello'); + }); + }); + + describe('formatServerCommand', () => { + it('should format http URL', () => { + const server = { + config: { httpUrl: 'http://localhost:3000' }, + } as MCPServerDisplayInfo; + expect(formatServerCommand(server)).toBe('http://localhost:3000 (http)'); + }); + + it('should format stdio command', () => { + const server = { + config: { command: 'node', args: ['server.js'] }, + } as MCPServerDisplayInfo; + expect(formatServerCommand(server)).toBe('node server.js (stdio)'); + }); + + it('should return Unknown for empty config', () => { + const server = { config: {} } as MCPServerDisplayInfo; + expect(formatServerCommand(server)).toBe('Unknown'); + }); + }); + + describe('isToolValid', () => { + it('should return true for valid tool with name and description', () => { + expect(isToolValid('toolName', 'A description')).toBe(true); + }); + + it('should return false for tool without name', () => { + expect(isToolValid(undefined, 'A description')).toBe(false); + expect(isToolValid('', 'A description')).toBe(false); + }); + + it('should return false for tool without description', () => { + expect(isToolValid('toolName', undefined)).toBe(false); + expect(isToolValid('toolName', '')).toBe(false); + }); + + it('should return false for tool without both name and description', () => { + expect(isToolValid(undefined, undefined)).toBe(false); + expect(isToolValid('', '')).toBe(false); + }); + }); + + describe('getToolInvalidReasons', () => { + it('should return empty array for valid tool', () => { + expect(getToolInvalidReasons('toolName', 'A description')).toEqual([]); + }); + + it('should return missing name reason', () => { + expect(getToolInvalidReasons(undefined, 'A description')).toEqual([ + 'missing name', + ]); + expect(getToolInvalidReasons('', 'A description')).toEqual([ + 'missing name', + ]); + }); + + it('should return missing description reason', () => { + expect(getToolInvalidReasons('toolName', undefined)).toEqual([ + 'missing description', + ]); + expect(getToolInvalidReasons('toolName', '')).toEqual([ + 'missing description', + ]); + }); + + it('should return both reasons when both are missing', () => { + expect(getToolInvalidReasons(undefined, undefined)).toEqual([ + 'missing name', + 'missing description', + ]); + expect(getToolInvalidReasons('', '')).toEqual([ + 'missing name', + 'missing description', + ]); + }); + }); +}); diff --git a/packages/cli/src/ui/components/mcp/utils.ts b/packages/cli/src/ui/components/mcp/utils.ts new file mode 100644 index 0000000000..4220fe7eb1 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/utils.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { MCPServerDisplayInfo, GroupedServers } from './types.js'; +import { SOURCE_DISPLAY_NAMES } from './constants.js'; + +/** + * 按来源分组服务器 + */ +export function groupServersBySource( + servers: MCPServerDisplayInfo[], +): GroupedServers[] { + const groups = new Map(); + + for (const server of servers) { + const existing = groups.get(server.source); + if (existing) { + existing.push(server); + } else { + groups.set(server.source, [server]); + } + } + + // 按优先级排序: user > project > extension + const sourceOrder = ['user', 'project', 'extension']; + const result: GroupedServers[] = []; + + for (const source of sourceOrder) { + const servers = groups.get(source); + if (servers && servers.length > 0) { + result.push({ + source, + displayName: SOURCE_DISPLAY_NAMES[source] || source, + servers, + }); + } + } + + return result; +} + +/** + * 获取状态颜色 + */ +export function getStatusColor( + status: string, +): 'green' | 'yellow' | 'red' | 'gray' { + switch (status) { + case 'connected': + return 'green'; + case 'connecting': + return 'yellow'; + case 'disconnected': + return 'red'; + default: + return 'gray'; + } +} + +/** + * 获取状态图标 + */ +export function getStatusIcon(status: string): string { + switch (status) { + case 'connected': + return '✓'; + case 'connecting': + return '…'; + case 'disconnected': + return '✗'; + default: + return '?'; + } +} + +/** + * 截断文本 + */ +export function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength - 3) + '...'; +} + +/** + * 格式化服务器命令显示 + */ +export function formatServerCommand(server: MCPServerDisplayInfo): string { + const config = server.config; + if (config.httpUrl) { + return `${config.httpUrl} (http)`; + } + if (config.url) { + return `${config.url} (sse)`; + } + if (config.command) { + const args = config.args?.join(' ') || ''; + return `${config.command} ${args} (stdio)`.trim(); + } + return 'Unknown'; +} + +/** + * Check if a tool is valid (has both name and description required by LLM) + * @param name - Tool name + * @param description - Tool description + * @returns boolean indicating if the tool is valid + */ +export function isToolValid(name?: string, description?: string): boolean { + return !!name && !!description; +} + +/** + * Get the reason why a tool is invalid + * @param name - Tool name + * @param description - Tool description + * @returns Array of missing fields + */ +export function getToolInvalidReasons( + name?: string, + description?: string, +): string[] { + const reasons: string[] = []; + if (!name) reasons.push('missing name'); + if (!description) reasons.push('missing description'); + return reasons; +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index af15e72b65..85ff046c73 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -74,6 +74,8 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // MCP dialog + closeMcpDialog: () => void; // Resume session dialog openResumeDialog: () => void; closeResumeDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 9d1a21e831..84f9e6052b 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -125,6 +125,8 @@ export interface UIState { // Subagent dialogs isSubagentCreateDialogOpen: boolean; isAgentsManagerDialogOpen: boolean; + // MCP dialog + isMcpDialogOpen: boolean; // Feedback dialog isFeedbackDialogOpen: boolean; } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 80c6bec350..1bbdd05fd5 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -78,6 +78,7 @@ interface SlashCommandProcessorActions { addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; openAgentsManagerDialog: () => void; + openMcpDialog: () => void; } /** @@ -476,6 +477,9 @@ export const useSlashCommandProcessor = ( case 'subagent_list': actions.openAgentsManagerDialog(); return { type: 'handled' }; + case 'mcp': + actions.openMcpDialog(); + return { type: 'handled' }; case 'approval-mode': actions.openApprovalModeDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useMcpDialog.ts b/packages/cli/src/ui/hooks/useMcpDialog.ts new file mode 100644 index 0000000000..3b444297f7 --- /dev/null +++ b/packages/cli/src/ui/hooks/useMcpDialog.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +export interface UseMcpDialogReturn { + isMcpDialogOpen: boolean; + openMcpDialog: () => void; + closeMcpDialog: () => void; +} + +export const useMcpDialog = (): UseMcpDialogReturn => { + const [isMcpDialogOpen, setIsMcpDialogOpen] = useState(false); + + const openMcpDialog = useCallback(() => { + setIsMcpDialogOpen(true); + }, []); + + const closeMcpDialog = useCallback(() => { + setIsMcpDialogOpen(false); + }, []); + + return { + isMcpDialogOpen, + openMcpDialog, + closeMcpDialog, + }; +}; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index bb9bbf6ec3..d8af76fea6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -459,7 +459,7 @@ export class Config { private readonly lspEnabled: boolean; private lspClient?: LspClient; private readonly allowedMcpServers?: string[]; - private readonly excludedMcpServers?: string[]; + private excludedMcpServers?: string[]; private sessionSubagents: SubagentConfig[]; private userMemory: string; private sdkMode: boolean; @@ -1252,17 +1252,25 @@ export class Config { ); } - if (this.excludedMcpServers) { - mcpServers = Object.fromEntries( - Object.entries(mcpServers).filter( - ([key]) => !this.excludedMcpServers?.includes(key), - ), - ); - } + // Note: We no longer filter out excluded servers here. + // The UI layer should check isMcpServerDisabled() to determine + // whether to show a server as disabled. return mcpServers; } + getExcludedMcpServers(): string[] | undefined { + return this.excludedMcpServers; + } + + setExcludedMcpServers(excluded: string[]): void { + this.excludedMcpServers = excluded; + } + + isMcpServerDisabled(serverName: string): boolean { + return this.excludedMcpServers?.includes(serverName) ?? false; + } + addMcpServers(servers: Record): void { if (this.initialized) { throw new Error('Cannot modify mcpServers after initialization'); diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 8043499322..7f3eb30537 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -743,6 +743,62 @@ describe('AnthropicContentConverter', () => { const result = await converter.convertGeminiToolsToAnthropic(tools); expect(result[0]?.input_schema?.type).toBe('object'); }); + + it('skips functions without name or description', async () => { + const tools = [ + { + functionDeclarations: [ + { + name: 'valid_tool', + description: 'A valid tool', + }, + { + name: 'missing_description', + // no description + }, + { + // no name + description: 'Missing name', + }, + { + // neither name nor description + parametersJsonSchema: { type: 'object' }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('valid_tool'); + }); + + it('skips functions with empty name or description', async () => { + const tools = [ + { + functionDeclarations: [ + { + name: 'valid_tool', + description: 'A valid tool', + }, + { + name: '', + description: 'Empty name', + }, + { + name: 'empty_description', + description: '', + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('valid_tool'); + }); }); describe('convertAnthropicResponseToGemini', () => { diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 7c774e2a07..ec1e24742b 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -91,7 +91,8 @@ export class AnthropicContentConverter { } for (const func of actualTool.functionDeclarations) { - if (!func.name) continue; + // Skip functions without name or description (required by Anthropic API) + if (!func.name || !func.description) continue; let inputSchema: Record | undefined; if (func.parametersJsonSchema) { diff --git a/packages/core/src/tools/mcp-client-manager.test.ts b/packages/core/src/tools/mcp-client-manager.test.ts index 051c9d87a4..140b78324e 100644 --- a/packages/core/src/tools/mcp-client-manager.test.ts +++ b/packages/core/src/tools/mcp-client-manager.test.ts @@ -44,6 +44,7 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}), getWorkspaceContext: () => ({}), getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); await manager.discoverAllMcpTools(mockConfig); @@ -68,6 +69,7 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}), getWorkspaceContext: () => ({}), getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); await manager.discoverAllMcpTools(mockConfig); @@ -97,11 +99,13 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}) as PromptRegistry, getWorkspaceContext: () => ({}) as WorkspaceContext, getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); // First connect to create the clients await manager.discoverAllMcpTools({ isTrustedFolder: () => true, + isMcpServerDisabled: () => false, } as unknown as Config); // Clear the disconnect calls from initial stop() in discoverAllMcpTools @@ -131,10 +135,12 @@ describe('McpClientManager', () => { getPromptRegistry: () => ({}) as PromptRegistry, getWorkspaceContext: () => ({}) as WorkspaceContext, getDebugMode: () => false, + isMcpServerDisabled: () => false, } as unknown as Config; const manager = new McpClientManager(mockConfig, {} as ToolRegistry); await manager.discoverAllMcpTools({ isTrustedFolder: () => true, + isMcpServerDisabled: () => false, } as unknown as Config); // Call stop multiple times - should not throw diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index 050875a884..ecc700739f 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -21,6 +21,27 @@ import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js'; const debugLogger = createDebugLogger('MCP'); +/** + * Configuration for MCP health monitoring + */ +export interface MCPHealthMonitorConfig { + /** Health check interval in milliseconds (default: 30000ms) */ + checkIntervalMs: number; + /** Number of consecutive failures before marking as disconnected (default: 3) */ + maxConsecutiveFailures: number; + /** Enable automatic reconnection (default: true) */ + autoReconnect: boolean; + /** Delay before reconnection attempt in milliseconds (default: 5000ms) */ + reconnectDelayMs: number; +} + +const DEFAULT_HEALTH_CONFIG: MCPHealthMonitorConfig = { + checkIntervalMs: 30000, // 30 seconds + maxConsecutiveFailures: 3, + autoReconnect: true, + reconnectDelayMs: 5000, // 5 seconds +}; + /** * Manages the lifecycle of multiple MCP clients, including local child processes. * This class is responsible for starting, stopping, and discovering tools from @@ -33,18 +54,24 @@ export class McpClientManager { private discoveryState: MCPDiscoveryState = MCPDiscoveryState.NOT_STARTED; private readonly eventEmitter?: EventEmitter; private readonly sendSdkMcpMessage?: SendSdkMcpMessage; + private healthConfig: MCPHealthMonitorConfig; + private healthCheckTimers: Map = new Map(); + private consecutiveFailures: Map = new Map(); + private isReconnecting: Map = new Map(); constructor( config: Config, toolRegistry: ToolRegistry, eventEmitter?: EventEmitter, sendSdkMcpMessage?: SendSdkMcpMessage, + healthConfig?: Partial, ) { this.cliConfig = config; this.toolRegistry = toolRegistry; this.eventEmitter = eventEmitter; this.sendSdkMcpMessage = sendSdkMcpMessage; + this.healthConfig = { ...DEFAULT_HEALTH_CONFIG, ...healthConfig }; } /** @@ -68,6 +95,12 @@ export class McpClientManager { this.eventEmitter?.emit('mcp-client-update', this.clients); const discoveryPromises = Object.entries(servers).map( async ([name, config]) => { + // Skip disabled servers + if (cliConfig.isMcpServerDisabled(name)) { + debugLogger.debug(`Skipping disabled MCP server: ${name}`); + return; + } + // For SDK MCP servers, pass the sendSdkMcpMessage callback const sdkCallback = isSdkMcpServerConfig(config) ? this.sendSdkMcpMessage @@ -160,6 +193,8 @@ export class McpClientManager { try { await client.connect(); await client.discover(cliConfig); + // Start health check for this server after successful discovery + this.startHealthCheck(serverName); } catch (error) { // Log the error but don't throw: callers expect best-effort discovery. debugLogger.error( @@ -177,6 +212,9 @@ export class McpClientManager { * This is the cleanup method to be called on application exit. */ async stop(): Promise { + // Stop all health checks first + this.stopAllHealthChecks(); + const disconnectionPromises = Array.from(this.clients.entries()).map( async ([name, client]) => { try { @@ -191,12 +229,267 @@ export class McpClientManager { await Promise.all(disconnectionPromises); this.clients.clear(); + this.consecutiveFailures.clear(); + this.isReconnecting.clear(); + } + + /** + * Disconnects a specific MCP server. + * @param serverName The name of the server to disconnect. + */ + async disconnectServer(serverName: string): Promise { + // Stop health check for this server + this.stopHealthCheck(serverName); + + const client = this.clients.get(serverName); + if (client) { + try { + await client.disconnect(); + } catch (error) { + debugLogger.error( + `Error disconnecting client '${serverName}': ${getErrorMessage(error)}`, + ); + } finally { + this.clients.delete(serverName); + this.consecutiveFailures.delete(serverName); + this.isReconnecting.delete(serverName); + this.eventEmitter?.emit('mcp-client-update', this.clients); + } + } } getDiscoveryState(): MCPDiscoveryState { return this.discoveryState; } + /** + * Gets the health monitoring configuration + */ + getHealthConfig(): MCPHealthMonitorConfig { + return { ...this.healthConfig }; + } + + /** + * Updates the health monitoring configuration + */ + updateHealthConfig(config: Partial): void { + this.healthConfig = { ...this.healthConfig, ...config }; + // Restart health checks with new configuration + this.stopAllHealthChecks(); + if (this.healthConfig.autoReconnect) { + this.startAllHealthChecks(); + } + } + + /** + * Starts health monitoring for a specific server + */ + private startHealthCheck(serverName: string): void { + if (!this.healthConfig.autoReconnect) { + return; + } + + // Clear existing timer if any + this.stopHealthCheck(serverName); + + const timer = setInterval(async () => { + await this.performHealthCheck(serverName); + }, this.healthConfig.checkIntervalMs); + + this.healthCheckTimers.set(serverName, timer); + } + + /** + * Stops health monitoring for a specific server + */ + private stopHealthCheck(serverName: string): void { + const timer = this.healthCheckTimers.get(serverName); + if (timer) { + clearInterval(timer); + this.healthCheckTimers.delete(serverName); + } + } + + /** + * Stops all health checks + */ + private stopAllHealthChecks(): void { + for (const [, timer] of this.healthCheckTimers.entries()) { + clearInterval(timer); + } + this.healthCheckTimers.clear(); + } + + /** + * Starts health checks for all connected servers + */ + private startAllHealthChecks(): void { + for (const serverName of this.clients.keys()) { + this.startHealthCheck(serverName); + } + } + + /** + * Performs a health check on a specific server + */ + private async performHealthCheck(serverName: string): Promise { + const client = this.clients.get(serverName); + if (!client) { + return; + } + + // Skip if already reconnecting + if (this.isReconnecting.get(serverName)) { + return; + } + + try { + // Check if client is connected by getting its status + const status = client.getStatus(); + + if (status !== MCPServerStatus.CONNECTED) { + // Connection is not healthy + const failures = (this.consecutiveFailures.get(serverName) || 0) + 1; + this.consecutiveFailures.set(serverName, failures); + + debugLogger.warn( + `Health check failed for server '${serverName}' (${failures}/${this.healthConfig.maxConsecutiveFailures})`, + ); + + if (failures >= this.healthConfig.maxConsecutiveFailures) { + // Trigger reconnection + await this.reconnectServer(serverName); + } + } else { + // Connection is healthy, reset failure count + this.consecutiveFailures.set(serverName, 0); + } + } catch (error) { + debugLogger.error( + `Error during health check for server '${serverName}': ${getErrorMessage(error)}`, + ); + } + } + + /** + * Reconnects a specific server + */ + private async reconnectServer(serverName: string): Promise { + if (this.isReconnecting.get(serverName)) { + return; + } + + this.isReconnecting.set(serverName, true); + debugLogger.info(`Attempting to reconnect to server '${serverName}'...`); + + try { + // Wait before reconnecting + await new Promise((resolve) => + setTimeout(resolve, this.healthConfig.reconnectDelayMs), + ); + + await this.discoverMcpToolsForServer(serverName, this.cliConfig); + + // Reset failure count on successful reconnection + this.consecutiveFailures.set(serverName, 0); + debugLogger.info(`Successfully reconnected to server '${serverName}'`); + } catch (error) { + debugLogger.error( + `Failed to reconnect to server '${serverName}': ${getErrorMessage(error)}`, + ); + } finally { + this.isReconnecting.set(serverName, false); + } + } + + /** + * Discovers tools incrementally for all configured servers. + * Only updates servers that have changed or are new. + */ + async discoverAllMcpToolsIncremental(cliConfig: Config): Promise { + if (!cliConfig.isTrustedFolder()) { + return; + } + + const servers = populateMcpServerCommand( + this.cliConfig.getMcpServers() || {}, + this.cliConfig.getMcpServerCommand(), + ); + + this.discoveryState = MCPDiscoveryState.IN_PROGRESS; + + // Find servers that are new or have changed configuration + const serversToUpdate: string[] = []; + const currentServerNames = new Set(this.clients.keys()); + const newServerNames = new Set(Object.keys(servers)); + + // Check for new servers or configuration changes + for (const [name] of Object.entries(servers)) { + const existingClient = this.clients.get(name); + if (!existingClient) { + // New server + serversToUpdate.push(name); + } else if (existingClient.getStatus() === MCPServerStatus.DISCONNECTED) { + // Disconnected server, try to reconnect + serversToUpdate.push(name); + } + // Note: Configuration change detection would require comparing + // the old and new config, which is not implemented here + } + + // Find removed servers + for (const name of currentServerNames) { + if (!newServerNames.has(name)) { + // Server was removed from configuration + await this.removeServer(name); + } + } + + // Update only the servers that need it + const discoveryPromises = serversToUpdate.map(async (name) => { + try { + await this.discoverMcpToolsForServer(name, cliConfig); + } catch (error) { + debugLogger.error( + `Error during incremental discovery for server '${name}': ${getErrorMessage(error)}`, + ); + } + }); + + await Promise.all(discoveryPromises); + + // Start health checks for all connected servers + if (this.healthConfig.autoReconnect) { + this.startAllHealthChecks(); + } + + this.discoveryState = MCPDiscoveryState.COMPLETED; + } + + /** + * Removes a server and its tools + */ + private async removeServer(serverName: string): Promise { + const client = this.clients.get(serverName); + if (client) { + try { + await client.disconnect(); + } catch (error) { + debugLogger.error( + `Error disconnecting removed server '${serverName}': ${getErrorMessage(error)}`, + ); + } + this.clients.delete(serverName); + this.stopHealthCheck(serverName); + this.consecutiveFailures.delete(serverName); + } + + // Remove tools for this server from registry + this.toolRegistry.removeMcpToolsByServer(serverName); + + this.eventEmitter?.emit('mcp-client-update', this.clients); + } + async readResource( serverName: string, uri: string, diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 4ba6c68931..5d48b68c79 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -360,7 +360,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< private readonly cliConfig?: Config, private readonly mcpClient?: McpDirectClient, private readonly mcpTimeout?: number, - private readonly annotations?: McpToolAnnotations, + readonly annotations?: McpToolAnnotations, ) { super( nameOverride ?? diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 1db7f7e59b..dc14bef865 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -229,6 +229,28 @@ export class ToolRegistry { } } + /** + * Disables an MCP server by removing its tools, prompts, and disconnecting the client. + * Also updates the config's exclusion list. + * @param serverName The name of the server to disable. + */ + async disableMcpServer(serverName: string): Promise { + // Remove tools from registry + this.removeMcpToolsByServer(serverName); + + // Remove prompts + this.config.getPromptRegistry().removePromptsByServer(serverName); + + // Disconnect the MCP client + await this.mcpClientManager.disconnectServer(serverName); + + // Update config's exclusion list + const currentExcluded = this.config.getExcludedMcpServers() || []; + if (!currentExcluded.includes(serverName)) { + this.config.setExcludedMcpServers([...currentExcluded, serverName]); + } + } + /** * Discovers tools from project (if available and configured). * Can be called multiple times to update discovered tools.