diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index f41c20eb7..20618330d 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -901,6 +901,19 @@ export default { connecting: 'verbindet', disconnected: 'getrennt', error: 'Fehler', + // 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)', + unknown: 'unbekannt', + 'Warning: This tool cannot be called by the LLM': + 'Warnung: Dieses Werkzeug kann nicht vom LLM aufgerufen werden', + Reason: 'Grund', + 'Tools must have both name and description to be used by the LLM.': + 'Werkzeuge müssen sowohl einen Namen als auch eine Beschreibung haben, um vom LLM verwendet zu werden.', 'Modify in progress:': 'Änderung in Bearbeitung:', 'Save and close external editor to continue': 'Speichern und externen Editor schließen, um fortzufahren', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 724426e6d..50538af92 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -785,6 +785,19 @@ export default { connecting: 'connecting', disconnected: 'disconnected', error: 'error', + // 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)', + unknown: 'unknown', + '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 diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 556b542d7..dd95a3ca9 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -641,6 +641,19 @@ export default { connecting: '接続中', disconnected: '切断済み', error: 'エラー', + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} 個の無効なツール', + invalid: '無効', + 'invalid: {{reason}}': '無効: {{reason}}', + 'missing name': '名前なし', + 'missing description': '説明なし', + '(unnamed)': '(名前なし)', + unknown: '不明', + 'Warning: This tool cannot be called by the LLM': + '警告: このツールはLLMによって呼び出すことができません', + Reason: '理由', + 'Tools must have both name and description to be used by the LLM.': + 'ツールはLLMによって使用されるには名前と説明の両方が必要です。', 'Modify in progress:': '変更中:', 'Save and close external editor to continue': '続行するには外部エディタを保存して閉じてください', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index a41848a4e..b89472621 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -907,6 +907,19 @@ export default { connecting: 'conectando', disconnected: 'desconectado', error: 'erro', + // 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)', + unknown: 'desconhecido', + 'Warning: This tool cannot be called by the LLM': + 'Aviso: Esta ferramenta não pode ser chamada pelo LLM', + Reason: 'Motivo', + 'Tools must have both name and description to be used by the LLM.': + 'As ferramentas devem ter tanto nome quanto descrição para serem usadas pelo LLM.', 'Modify in progress:': 'Modificação em progresso:', 'Save and close external editor to continue': 'Salve e feche o editor externo para continuar', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index aeb159a11..97ccfcb81 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -908,6 +908,19 @@ export default { connecting: 'подключение', disconnected: 'отключен', error: 'ошибка', + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} недействительных инструментов', + invalid: 'недействительный', + 'invalid: {{reason}}': 'недействительно: {{reason}}', + 'missing name': 'отсутствует имя', + 'missing description': 'отсутствует описание', + '(unnamed)': '(без имени)', + unknown: 'неизвестно', + 'Warning: This tool cannot be called by the LLM': + 'Предупреждение: Этот инструмент не может быть вызван LLM', + Reason: 'Причина', + 'Tools must have both name and description to be used by the LLM.': + 'Инструменты должны иметь как имя, так и описание, чтобы использоваться LLM.', 'Modify in progress:': 'Идет изменение:', 'Save and close external editor to continue': 'Сохраните и закройте внешний редактор для продолжения', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index b24379d4d..57822793a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -736,6 +736,19 @@ export default { connecting: '连接中', disconnected: '已断开', error: '错误', + // Invalid tool related translations + '{{count}} invalid tools': '{{count}} 个无效工具', + invalid: '无效', + 'invalid: {{reason}}': '无效:{{reason}}', + 'missing name': '缺少名称', + 'missing description': '缺少描述', + '(unnamed)': '(未命名)', + unknown: '未知', + '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 diff --git a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx index 85468a8ad..3ccda0a14 100644 --- a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -30,6 +30,7 @@ import { 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'); @@ -105,6 +106,11 @@ export const MCPManagementDialog: React.FC = ({ // 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, @@ -112,6 +118,7 @@ export const MCPManagementDialog: React.FC = ({ scope, config: serverConfig, toolCount: serverTools.length, + invalidToolCount, promptCount: serverPrompts.length, isDisabled, }); @@ -191,13 +198,26 @@ export const MCPManagementDialog: React.FC = ({ mcpTools.push(tool); } } - return mcpTools.map((tool) => ({ - name: tool.name, - description: tool.description, - serverName: tool.serverName, - schema: tool.parameterSchema as object | undefined, - annotations: tool.annotations, - })); + 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 diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx index dc9e1ba35..24f57d9dd 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -156,6 +156,13 @@ export const ServerDetailStep: React.FC = ({ {server.toolCount}{' '} {server.toolCount === 1 ? t('tool') : t('tools')} + {!!server.invalidToolCount && server.invalidToolCount > 0 && ( + + {' '} + ({server.invalidToolCount}{' '} + {server.invalidToolCount === 1 ? t('invalid') : t('invalid')}) + + )} diff --git a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx index 975ec9ed1..db6a218c0 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx @@ -154,6 +154,15 @@ export const ServerListStep: React.FC = ({ {server.isDisabled && ( {t('(disabled)')} )} + {/* 显示无效工具警告 */} + {!!server.invalidToolCount && server.invalidToolCount > 0 && ( + + {' '} + {t('{{count}} invalid tools', { + count: String(server.invalidToolCount), + })} + + )} ); })} diff --git a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx index 2cd6fa5f0..0bf32b860 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx @@ -137,6 +137,23 @@ export const ToolDetailStep: React.FC = ({ return ( + {/* 无效工具警告 */} + {!tool.isValid && ( + + + {t('Warning: This tool cannot be called by the LLM')} + + + {t('Reason')}: {tool.invalidReason || t('unknown')} + + + {t( + 'Tools must have both name and description to be used by the LLM.', + )} + + + )} + {/* 工具描述 */} {tool.description && ( diff --git a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx index 4f88b1a53..8166ed6cf 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx @@ -111,7 +111,18 @@ export const ToolListStep: React.FC = ({ > {tool.name} - {annotations && ( + {/* 显示无效工具警告 */} + {!tool.isValid && ( + <> + + + {t('invalid: {{reason}}', { + reason: tool.invalidReason || t('unknown'), + })} + + + )} + {annotations && tool.isValid && ( <> {annotations} diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts index 85bad67df..1133592bb 100644 --- a/packages/cli/src/ui/components/mcp/types.ts +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -41,6 +41,8 @@ export interface MCPServerDisplayInfo { config: MCPServerConfig; /** 工具数量 */ toolCount: number; + /** 无效工具数量(缺少name或description) */ + invalidToolCount?: number; /** Prompt数量 */ promptCount: number; /** 错误信息 */ @@ -69,6 +71,10 @@ export interface MCPToolDisplayInfo { idempotentHint?: boolean; openWorldHint?: boolean; }; + /** 工具是否有效(有name和description才能被LLM调用) */ + isValid: boolean; + /** 无效原因(当isValid为false时) */ + invalidReason?: string; } /** diff --git a/packages/cli/src/ui/components/mcp/utils.test.ts b/packages/cli/src/ui/components/mcp/utils.test.ts new file mode 100644 index 000000000..3b058ba55 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/utils.test.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + groupServersBySource, + getStatusColor, + getStatusIcon, + truncateText, + formatServerCommand, + isToolValid, + getToolInvalidReasons, +} from './utils.js'; +import type { MCPServerDisplayInfo } from './types.js'; +import { MCPServerStatus } from '@qwen-code/qwen-code-core'; + +describe('MCP utils', () => { + describe('groupServersBySource', () => { + it('should group servers by source', () => { + const servers: MCPServerDisplayInfo[] = [ + { + name: 'server1', + status: MCPServerStatus.CONNECTED, + source: 'user', + scope: 'user', + config: { command: 'cmd1' }, + toolCount: 1, + promptCount: 0, + isDisabled: false, + }, + { + name: 'server2', + status: MCPServerStatus.CONNECTED, + source: 'extension', + scope: 'extension', + config: { command: 'cmd2' }, + toolCount: 2, + promptCount: 0, + isDisabled: false, + }, + ]; + + const result = groupServersBySource(servers); + + expect(result).toHaveLength(2); + expect(result[0].source).toBe('user'); + expect(result[0].servers).toHaveLength(1); + expect(result[1].source).toBe('extension'); + }); + }); + + describe('getStatusColor', () => { + it('should return correct colors for each status', () => { + expect(getStatusColor(MCPServerStatus.CONNECTED)).toBe('green'); + expect(getStatusColor(MCPServerStatus.CONNECTING)).toBe('yellow'); + expect(getStatusColor(MCPServerStatus.DISCONNECTED)).toBe('red'); + expect(getStatusColor('unknown' as MCPServerStatus)).toBe('gray'); + }); + }); + + describe('getStatusIcon', () => { + it('should return correct icons for each status', () => { + expect(getStatusIcon(MCPServerStatus.CONNECTED)).toBe('✓'); + expect(getStatusIcon(MCPServerStatus.CONNECTING)).toBe('…'); + expect(getStatusIcon(MCPServerStatus.DISCONNECTED)).toBe('✗'); + expect(getStatusIcon('unknown' as MCPServerStatus)).toBe('?'); + }); + }); + + describe('truncateText', () => { + it('should truncate text longer than maxLength', () => { + expect(truncateText('hello world', 8)).toBe('hello...'); + }); + + it('should not truncate text shorter than maxLength', () => { + expect(truncateText('hello', 10)).toBe('hello'); + }); + }); + + describe('formatServerCommand', () => { + it('should format http URL', () => { + const server = { + config: { httpUrl: 'http://localhost:3000' }, + } as MCPServerDisplayInfo; + expect(formatServerCommand(server)).toBe('http://localhost:3000 (http)'); + }); + + it('should format stdio command', () => { + const server = { + config: { command: 'node', args: ['server.js'] }, + } as MCPServerDisplayInfo; + expect(formatServerCommand(server)).toBe('node server.js (stdio)'); + }); + + it('should return Unknown for empty config', () => { + const server = { config: {} } as MCPServerDisplayInfo; + expect(formatServerCommand(server)).toBe('Unknown'); + }); + }); + + describe('isToolValid', () => { + it('should return true for valid tool with name and description', () => { + expect(isToolValid('toolName', 'A description')).toBe(true); + }); + + it('should return false for tool without name', () => { + expect(isToolValid(undefined, 'A description')).toBe(false); + expect(isToolValid('', 'A description')).toBe(false); + }); + + it('should return false for tool without description', () => { + expect(isToolValid('toolName', undefined)).toBe(false); + expect(isToolValid('toolName', '')).toBe(false); + }); + + it('should return false for tool without both name and description', () => { + expect(isToolValid(undefined, undefined)).toBe(false); + expect(isToolValid('', '')).toBe(false); + }); + }); + + describe('getToolInvalidReasons', () => { + it('should return empty array for valid tool', () => { + expect(getToolInvalidReasons('toolName', 'A description')).toEqual([]); + }); + + it('should return missing name reason', () => { + expect(getToolInvalidReasons(undefined, 'A description')).toEqual([ + 'missing name', + ]); + expect(getToolInvalidReasons('', 'A description')).toEqual([ + 'missing name', + ]); + }); + + it('should return missing description reason', () => { + expect(getToolInvalidReasons('toolName', undefined)).toEqual([ + 'missing description', + ]); + expect(getToolInvalidReasons('toolName', '')).toEqual([ + 'missing description', + ]); + }); + + it('should return both reasons when both are missing', () => { + expect(getToolInvalidReasons(undefined, undefined)).toEqual([ + 'missing name', + 'missing description', + ]); + expect(getToolInvalidReasons('', '')).toEqual([ + 'missing name', + 'missing description', + ]); + }); + }); +}); diff --git a/packages/cli/src/ui/components/mcp/utils.ts b/packages/cli/src/ui/components/mcp/utils.ts index 709c8f947..4220fe7eb 100644 --- a/packages/cli/src/ui/components/mcp/utils.ts +++ b/packages/cli/src/ui/components/mcp/utils.ts @@ -101,3 +101,29 @@ export function formatServerCommand(server: MCPServerDisplayInfo): string { } return 'Unknown'; } + +/** + * Check if a tool is valid (has both name and description required by LLM) + * @param name - Tool name + * @param description - Tool description + * @returns boolean indicating if the tool is valid + */ +export function isToolValid(name?: string, description?: string): boolean { + return !!name && !!description; +} + +/** + * Get the reason why a tool is invalid + * @param name - Tool name + * @param description - Tool description + * @returns Array of missing fields + */ +export function getToolInvalidReasons( + name?: string, + description?: string, +): string[] { + const reasons: string[] = []; + if (!name) reasons.push('missing name'); + if (!description) reasons.push('missing description'); + return reasons; +} diff --git a/packages/core/src/core/anthropicContentGenerator/converter.test.ts b/packages/core/src/core/anthropicContentGenerator/converter.test.ts index 804349932..7f3eb3053 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.test.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.test.ts @@ -743,6 +743,62 @@ describe('AnthropicContentConverter', () => { const result = await converter.convertGeminiToolsToAnthropic(tools); expect(result[0]?.input_schema?.type).toBe('object'); }); + + it('skips functions without name or description', async () => { + const tools = [ + { + functionDeclarations: [ + { + name: 'valid_tool', + description: 'A valid tool', + }, + { + name: 'missing_description', + // no description + }, + { + // no name + description: 'Missing name', + }, + { + // neither name nor description + parametersJsonSchema: { type: 'object' }, + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('valid_tool'); + }); + + it('skips functions with empty name or description', async () => { + const tools = [ + { + functionDeclarations: [ + { + name: 'valid_tool', + description: 'A valid tool', + }, + { + name: '', + description: 'Empty name', + }, + { + name: 'empty_description', + description: '', + }, + ], + }, + ] as Tool[]; + + const result = await converter.convertGeminiToolsToAnthropic(tools); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('valid_tool'); + }); }); describe('convertAnthropicResponseToGemini', () => { diff --git a/packages/core/src/core/anthropicContentGenerator/converter.ts b/packages/core/src/core/anthropicContentGenerator/converter.ts index 7c774e2a0..ec1e24742 100644 --- a/packages/core/src/core/anthropicContentGenerator/converter.ts +++ b/packages/core/src/core/anthropicContentGenerator/converter.ts @@ -91,7 +91,8 @@ export class AnthropicContentConverter { } for (const func of actualTool.functionDeclarations) { - if (!func.name) continue; + // Skip functions without name or description (required by Anthropic API) + if (!func.name || !func.description) continue; let inputSchema: Record | undefined; if (func.parametersJsonSchema) {