mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
Merge pull request #1831 from QwenLM/feat/mcp-tui
feat: Enhance MCP Management TUI with dynamic enable/disable and runtime updates
This commit is contained in:
commit
f5ec6ae7a3
36 changed files with 3085 additions and 313 deletions
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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] <name> <commandOrUrl> [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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
'続行するには外部エディタを保存して閉じてください',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.':
|
||||
|
|
|
|||
|
|
@ -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': '是,本次会话总是允许',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<typeof import('@qwen-code/qwen-code-core')>();
|
||||
|
|
@ -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<typeof createMockCommandContext>;
|
||||
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, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
|
||||
// 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),
|
||||
});
|
||||
|
||||
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');
|
||||
it('should open MCP management dialog with desc argument', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'desc');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: true,
|
||||
showTips: false,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display descriptions when nodesc argument is used', async () => {
|
||||
await mcpCommand.action!(mockContext, 'nodesc');
|
||||
it('should open MCP management dialog with nodesc argument', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'nodesc');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: false,
|
||||
showTips: false,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void | MessageActionReturn> => {
|
||||
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<void | SlashCommandActionReturn> => {
|
||||
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<OpenDialogActionReturn> => ({
|
||||
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<void | SlashCommandActionReturn> =>
|
||||
// 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<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -148,7 +148,8 @@ export interface OpenDialogActionReturn {
|
|||
| 'subagent_list'
|
||||
| 'permissions'
|
||||
| 'approval-mode'
|
||||
| 'resume';
|
||||
| 'resume'
|
||||
| 'mcp';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
|
||||
}
|
||||
|
||||
if (uiState.isResumeDialogOpen) {
|
||||
return (
|
||||
<SessionPicker
|
||||
|
|
|
|||
554
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
Normal file
554
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, 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 {
|
||||
MCPManagementDialogProps,
|
||||
MCPServerDisplayInfo,
|
||||
MCPToolDisplayInfo,
|
||||
} from './types.js';
|
||||
import { MCP_MANAGEMENT_STEPS } from './types.js';
|
||||
import { ServerListStep } from './steps/ServerListStep.js';
|
||||
import { ServerDetailStep } from './steps/ServerDetailStep.js';
|
||||
import { ToolListStep } from './steps/ToolListStep.js';
|
||||
import { ToolDetailStep } from './steps/ToolDetailStep.js';
|
||||
import { DisableScopeSelectStep } from './steps/DisableScopeSelectStep.js';
|
||||
import { useConfig } from '../../contexts/ConfigContext.js';
|
||||
import {
|
||||
getMCPServerStatus,
|
||||
DiscoveredMCPTool,
|
||||
type MCPServerConfig,
|
||||
type AnyDeclarativeTool,
|
||||
type DiscoveredMCPPrompt,
|
||||
createDebugLogger,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { loadSettings, SettingScope } from '../../../config/settings.js';
|
||||
import { isToolValid, getToolInvalidReasons } from './utils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('MCP_DIALOG');
|
||||
|
||||
export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
const config = useConfig();
|
||||
|
||||
const [servers, setServers] = useState<MCPServerDisplayInfo[]>([]);
|
||||
const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);
|
||||
const [selectedTool, setSelectedTool] = useState<MCPToolDisplayInfo | null>(
|
||||
null,
|
||||
);
|
||||
const [navigationStack, setNavigationStack] = useState<string[]>([
|
||||
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 (
|
||||
<Box>
|
||||
<Text color={theme.text.accent} bold>
|
||||
{headerText}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, selectedServer, selectedTool]);
|
||||
|
||||
// Render step content
|
||||
const renderStepContent = useCallback(() => {
|
||||
if (isLoading) {
|
||||
return <Text color={theme.text.secondary}>{t('Loading...')}</Text>;
|
||||
}
|
||||
|
||||
const currentStep = getCurrentStep();
|
||||
|
||||
switch (currentStep) {
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
|
||||
return (
|
||||
<ServerListStep servers={servers} onSelect={handleSelectServer} />
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
|
||||
return (
|
||||
<ServerDetailStep
|
||||
server={selectedServer}
|
||||
onViewTools={handleViewTools}
|
||||
onReconnect={handleReconnect}
|
||||
onDisable={handleDisable}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
return (
|
||||
<DisableScopeSelectStep
|
||||
server={selectedServer}
|
||||
onSelectScope={handleSelectDisableScope}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
|
||||
return (
|
||||
<ToolListStep
|
||||
tools={getServerTools()}
|
||||
serverName={selectedServer?.name || ''}
|
||||
onSelect={handleSelectTool}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
|
||||
return (
|
||||
<ToolDetailStep tool={selectedTool} onBack={handleNavigateBack} />
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('Unknown step')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}, [
|
||||
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 (
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>{footerText}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [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 (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
gap={1}
|
||||
>
|
||||
{renderStepHeader()}
|
||||
{renderStepContent()}
|
||||
{renderStepFooter()}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
47
packages/cli/src/ui/components/mcp/constants.ts
Normal file
47
packages/cli/src/ui/components/mcp/constants.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
user: 'User MCPs',
|
||||
project: 'Project MCPs',
|
||||
extension: 'Extension MCPs',
|
||||
};
|
||||
|
||||
/**
|
||||
* 状态显示文本
|
||||
*/
|
||||
export const STATUS_TEXT: Record<string, string> = {
|
||||
connected: 'connected',
|
||||
connecting: 'connecting',
|
||||
disconnected: 'failed',
|
||||
};
|
||||
30
packages/cli/src/ui/components/mcp/index.ts
Normal file
30
packages/cli/src/ui/components/mcp/index.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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<DisableScopeSelectStepProps> = ({
|
||||
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 (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No server selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary}>
|
||||
{t('Disable server:')} {server.name}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Select where to add the server to the exclude list:')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<RadioButtonSelect<'user' | 'workspace'>
|
||||
items={scopes}
|
||||
onHighlight={(value: 'user' | 'workspace') => setSelectedScope(value)}
|
||||
onSelect={(value: 'user' | 'workspace') => onSelectScope(value)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Press Enter to confirm, Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
223
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
Normal file
223
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
Normal file
|
|
@ -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<ServerDetailStepProps> = ({
|
||||
server,
|
||||
onViewTools,
|
||||
onReconnect,
|
||||
onDisable,
|
||||
onBack,
|
||||
}) => {
|
||||
const [selectedAction, setSelectedAction] =
|
||||
useState<ServerAction>('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 (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No server selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* 服务器详情 */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Status:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
statusColor === 'green'
|
||||
? theme.status.success
|
||||
: statusColor === 'yellow'
|
||||
? theme.status.warning
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{getStatusIcon(server.status)} {t(server.status)}
|
||||
{server.isDisabled && (
|
||||
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Source:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{server.scope === 'user'
|
||||
? t('User Settings')
|
||||
: server.scope === 'workspace'
|
||||
? t('Workspace Settings')
|
||||
: t('Extension')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Command:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text wrap="truncate">{formatServerCommand(server)}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{server.config.cwd && (
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Working Directory:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text wrap="truncate">{server.config.cwd}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Capabilities:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
{server.toolCount > 0 ? t('tools') : ''}
|
||||
{server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''}
|
||||
{server.promptCount > 0 ? t('prompts') : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Tools:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
{server.toolCount}{' '}
|
||||
{server.toolCount === 1 ? t('tool') : t('tools')}
|
||||
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
|
||||
<Text color={theme.status.warning}>
|
||||
{' '}
|
||||
({server.invalidToolCount}{' '}
|
||||
{server.invalidToolCount === 1 ? t('invalid') : t('invalid')})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{server.errorMessage && (
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.status.error}>{t('Error:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.status.error} wrap="wrap">
|
||||
{server.errorMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 操作列表 */}
|
||||
<Box>
|
||||
<RadioButtonSelect<ServerAction>
|
||||
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;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
185
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
Normal file
185
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
Normal file
|
|
@ -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<ServerListStepProps> = ({
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No MCP servers configured.')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Add MCP servers to your settings to get started.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算当前选中项在分组中的位置
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
{/* 服务器统计 */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{servers.length} {servers.length === 1 ? t('server') : t('servers')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 分组服务器列表 */}
|
||||
{groupedServers.map((group, groupIndex) => (
|
||||
<Box key={group.source} flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{group.displayName}
|
||||
{group.servers[0]?.configPath && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
({group.servers[0].configPath})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{group.servers.map((server, itemIndex) => {
|
||||
const isSelected =
|
||||
groupIndex === currentPosition.groupIndex &&
|
||||
itemIndex === currentPosition.itemIndex;
|
||||
const statusColor = getStatusColor(server.status);
|
||||
|
||||
return (
|
||||
<Box key={server.name}>
|
||||
<Box minWidth={2}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 服务器名称 - 固定宽度 */}
|
||||
<Box width={serverNameWidth}>
|
||||
<Text
|
||||
color={
|
||||
isSelected ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
wrap="truncate"
|
||||
>
|
||||
{server.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}> · </Text>
|
||||
{/* 状态图标和文本 */}
|
||||
<Text
|
||||
color={
|
||||
statusColor === 'green'
|
||||
? theme.status.success
|
||||
: statusColor === 'yellow'
|
||||
? theme.status.warning
|
||||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{getStatusIcon(server.status)} {t(server.status)}
|
||||
</Text>
|
||||
{/* 显示 Scope 和禁用状态 */}
|
||||
<Text color={theme.text.secondary}> [{server.scope}]</Text>
|
||||
{server.isDisabled && (
|
||||
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
|
||||
)}
|
||||
{/* 显示无效工具警告 */}
|
||||
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
|
||||
<Text color={theme.status.warning}>
|
||||
{' '}
|
||||
{t('{{count}} invalid tools', {
|
||||
count: String(server.invalidToolCount),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* 提示信息 */}
|
||||
{servers.some((s) => s.status === 'disconnected') && (
|
||||
<Box>
|
||||
<Text color={theme.status.warning}>
|
||||
※ {t('Run qwen --debug to see error logs')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
217
packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx
Normal file
217
packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx
Normal file
|
|
@ -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<string, unknown>,
|
||||
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 (
|
||||
<Box key={name} flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>• {name}</Text>
|
||||
{isRequired && (
|
||||
<Text color={theme.status.error}> ({t('required')})</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{t('Type')}: </Text>
|
||||
<Text color={theme.status.success}>{type}</Text>
|
||||
</Box>
|
||||
{description && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{truncate(description, 80)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{enumValues && enumValues.length > 0 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enum')}: {enumValues.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{defaultValue !== undefined && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Default')}:{' '}
|
||||
{typeof defaultValue === 'string'
|
||||
? `"${truncate(defaultValue, 30)}"`
|
||||
: String(defaultValue)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染参数列表
|
||||
*/
|
||||
const ParametersList: React.FC<{
|
||||
properties: Record<string, unknown>;
|
||||
required: string[];
|
||||
}> = ({ properties, required }) => {
|
||||
const requiredSet = new Set(required);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>{t('Parameters')}:</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{Object.entries(properties).map(([name, param]) =>
|
||||
renderParameter(
|
||||
name,
|
||||
param as Record<string, unknown>,
|
||||
requiredSet.has(name),
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 提取并展示schema的关键信息,使用类似示例的格式
|
||||
*/
|
||||
const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => {
|
||||
const obj = schema as Record<string, unknown>;
|
||||
const properties = obj['properties'] as Record<string, unknown> | undefined;
|
||||
const required = (obj['required'] as string[]) || [];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* 参数列表 */}
|
||||
{properties && Object.keys(properties).length > 0 && (
|
||||
<ParametersList properties={properties} required={required} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
|
||||
tool,
|
||||
onBack,
|
||||
}) => {
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
if (!tool) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={theme.status.error}>{t('No tool selected')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
{/* 无效工具警告 */}
|
||||
{!tool.isValid && (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text color={theme.status.error} bold>
|
||||
{t('Warning: This tool cannot be called by the LLM')}
|
||||
</Text>
|
||||
<Text color={theme.status.error}>
|
||||
{t('Reason')}: {tool.invalidReason || t('unknown')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'Tools must have both name and description to be used by the LLM.',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 工具描述 */}
|
||||
{tool.description && (
|
||||
<Box>
|
||||
<Text wrap="wrap">{tool.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 工具注解 */}
|
||||
{tool.annotations && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Annotations')}:</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{tool.annotations.title && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Title')}: {tool.annotations.title}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.readOnlyHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Read Only')}:{' '}
|
||||
{tool.annotations.readOnlyHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.destructiveHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Destructive')}:{' '}
|
||||
{tool.annotations.destructiveHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.idempotentHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Idempotent')}:{' '}
|
||||
{tool.annotations.idempotentHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.openWorldHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Open World')}:{' '}
|
||||
{tool.annotations.openWorldHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Schema */}
|
||||
{tool.schema && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<SchemaSummary schema={tool.schema} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 所属服务器 */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Server')}: {tool.serverName}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
157
packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
Normal file
157
packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
Normal file
|
|
@ -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<ToolListStepProps> = ({
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No tools available for this server.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Box flexDirection="column">
|
||||
{/* 标题 */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>{t('Tools for {{name}}', { name: serverName })}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
({tools.length} {tools.length === 1 ? t('tool') : t('tools')})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 工具列表 */}
|
||||
<Box flexDirection="column">
|
||||
{displayTools.map((tool, index) => {
|
||||
const actualIndex = scrollOffset + index;
|
||||
const isSelected = actualIndex === selectedIndex;
|
||||
const annotations = getToolAnnotations(tool);
|
||||
|
||||
return (
|
||||
<Box key={tool.name}>
|
||||
{/* 选择器和序号 */}
|
||||
<Box minWidth={4}>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{actualIndex + 1}.</Text>
|
||||
</Box>
|
||||
{/* 工具名称 - 固定宽度 */}
|
||||
<Box width={toolNameWidth}>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
wrap="truncate"
|
||||
>
|
||||
{tool.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 显示无效工具警告 */}
|
||||
{!tool.isValid && (
|
||||
<Text color={theme.status.warning}>
|
||||
{t('invalid: {{reason}}', {
|
||||
reason: tool.invalidReason || t('unknown'),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
{annotations && tool.isValid && (
|
||||
<Text color={theme.text.secondary}>{annotations}</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* 滚动提示 */}
|
||||
{tools.length > VISIBLE_TOOLS_COUNT && (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{scrollOffset > 0 ? '↑ ' : ' '}
|
||||
{t('{{current}}/{{total}}', {
|
||||
current: (selectedIndex + 1).toString(),
|
||||
total: tools.length.toString(),
|
||||
})}
|
||||
{scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
180
packages/cli/src/ui/components/mcp/types.ts
Normal file
180
packages/cli/src/ui/components/mcp/types.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
159
packages/cli/src/ui/components/mcp/utils.test.ts
Normal file
159
packages/cli/src/ui/components/mcp/utils.test.ts
Normal file
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
packages/cli/src/ui/components/mcp/utils.ts
Normal file
129
packages/cli/src/ui/components/mcp/utils.ts
Normal file
|
|
@ -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<string, MCPServerDisplayInfo[]>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -74,6 +74,8 @@ export interface UIActions {
|
|||
// Subagent dialogs
|
||||
closeSubagentCreateDialog: () => void;
|
||||
closeAgentsManagerDialog: () => void;
|
||||
// MCP dialog
|
||||
closeMcpDialog: () => void;
|
||||
// Resume session dialog
|
||||
openResumeDialog: () => void;
|
||||
closeResumeDialog: () => void;
|
||||
|
|
|
|||
|
|
@ -125,6 +125,8 @@ export interface UIState {
|
|||
// Subagent dialogs
|
||||
isSubagentCreateDialogOpen: boolean;
|
||||
isAgentsManagerDialogOpen: boolean;
|
||||
// MCP dialog
|
||||
isMcpDialogOpen: boolean;
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
31
packages/cli/src/ui/hooks/useMcpDialog.ts
Normal file
31
packages/cli/src/ui/hooks/useMcpDialog.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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<string, MCPServerConfig>): void {
|
||||
if (this.initialized) {
|
||||
throw new Error('Cannot modify mcpServers after initialization');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | undefined;
|
||||
if (func.parametersJsonSchema) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, NodeJS.Timeout> = new Map();
|
||||
private consecutiveFailures: Map<string, number> = new Map();
|
||||
private isReconnecting: Map<string, boolean> = new Map();
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
toolRegistry: ToolRegistry,
|
||||
eventEmitter?: EventEmitter,
|
||||
sendSdkMcpMessage?: SendSdkMcpMessage,
|
||||
healthConfig?: Partial<MCPHealthMonitorConfig>,
|
||||
) {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
// 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<MCPHealthMonitorConfig>): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
// 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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue