From 9e34b5db4a536d50091f0e78556329b60cd5270c Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 9 Mar 2026 12:05:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=87=8D=E5=86=99=20AuthenticateStep=20?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E6=AD=A3=E7=A1=AE=E7=9A=84=20OAuth=20?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按照 mcpCommand.ts 中的 auth 实现重写 AuthenticateStep - 使用 MCPOAuthProvider + MCPOAuthTokenStorage 进行实际 OAuth 认证 - 通过 appEvents/AppEvent.OauthDisplayMessage 展示认证过程消息 - 认证成功后自动触发 tool re-discovery 和 client tools 更新 - 只在 server.config.oauth?.enabled 为 true 时显示 Authenticate 选项 - 认证成功后 reload servers 刷新列表 --- .../ui/components/mcp/MCPManagementDialog.tsx | 4 +- .../components/mcp/steps/AuthenticateStep.tsx | 239 ++++++++++-------- .../components/mcp/steps/ServerDetailStep.tsx | 80 +++--- .../components/mcp/steps/ServerListStep.tsx | 29 ++- .../ui/components/mcp/steps/ToolListStep.tsx | 8 +- 5 files changed, 192 insertions(+), 168 deletions(-) diff --git a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx index 7c04a13cb..aad839f71 100644 --- a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -529,8 +529,7 @@ export const MCPManagementDialog: React.FC = ({ { - // TODO: 认证成功后重新加载服务器列表 - handleNavigateBack(); + void reloadServers(); }} onBack={handleNavigateBack} /> @@ -558,6 +557,7 @@ export const MCPManagementDialog: React.FC = ({ handleSelectTool, handleSelectDisableScope, getServerTools, + reloadServers, ]); // Render step footer diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx index a2535ff82..f1369fe45 100644 --- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -4,85 +4,121 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState } from 'react'; +import { useState, useCallback, useRef } 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 { AuthenticateStepProps } from '../types.js'; -import type { MCPServerConfig } from '@qwen-code/qwen-code-core'; +import { useConfig } from '../../../contexts/ConfigContext.js'; +import { + MCPOAuthProvider, + MCPOAuthTokenStorage, + getErrorMessage, +} from '@qwen-code/qwen-code-core'; +import { appEvents, AppEvent } from '../../../../utils/events.js'; -// TODO: 稍后从 utils.ts 导入此函数 -const getOAuthConfigFromServerConfig = (_config: MCPServerConfig): unknown => - null; - -type AuthAction = 'authenticate' | 'back'; +type AuthState = 'idle' | 'authenticating' | 'success' | 'error'; export const AuthenticateStep: React.FC = ({ server, + onSuccess, onBack, }) => { - const [selectedAction, setSelectedAction] = useState('back'); - const [isAuthenticating, setIsAuthenticating] = useState(false); - const [authError, setAuthError] = useState(null); + const config = useConfig(); + const [authState, setAuthState] = useState('idle'); + const [messages, setMessages] = useState([]); + const [errorMessage, setErrorMessage] = useState(null); + const isRunning = useRef(false); - const actions = [ - { - key: 'authenticate', - label: t('Authenticate'), - value: 'authenticate' as const, - }, - { - key: 'back', - label: t('Back'), - value: 'back' as const, - }, - ]; + const runAuthentication = useCallback(async () => { + if (!server || !config || isRunning.current) return; + isRunning.current = true; + + setAuthState('authenticating'); + setMessages([]); + setErrorMessage(null); + + // Listen for OAuth display messages (same as mcpCommand.ts) + const displayListener = (message: string) => { + setMessages((prev) => [...prev, message]); + }; + appEvents.on(AppEvent.OauthDisplayMessage, displayListener); + + try { + setMessages([ + t("Starting OAuth authentication for MCP server '{{name}}'...", { + name: server.name, + }), + ]); + + let oauthConfig = server.config.oauth; + if (!oauthConfig) { + oauthConfig = { enabled: false }; + } + + const mcpServerUrl = server.config.httpUrl || server.config.url; + const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); + await authProvider.authenticate( + server.name, + oauthConfig, + mcpServerUrl, + appEvents, + ); + + setMessages((prev) => [ + ...prev, + t("Successfully authenticated and refreshed tools for '{{name}}'.", { + name: server.name, + }), + ]); + + // Trigger tool re-discovery to pick up authenticated server + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + setMessages((prev) => [ + ...prev, + t("Re-discovering tools from '{{name}}'...", { + name: server.name, + }), + ]); + await toolRegistry.discoverToolsForServer(server.name); + } + + // Update the client with the new tools + const geminiClient = config.getGeminiClient(); + if (geminiClient) { + await geminiClient.setTools(); + } + + setAuthState('success'); + onSuccess?.(); + } catch (error) { + setErrorMessage(getErrorMessage(error)); + setAuthState('error'); + } finally { + isRunning.current = false; + appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); + } + }, [server, config, onSuccess]); useKeypress( (key) => { if (key.name === 'escape') { - onBack(); - } else if (key.name === 'return' && !isAuthenticating) { - switch (selectedAction) { - case 'authenticate': - handleAuthenticate(); - break; - case 'back': - onBack(); - break; - default: - break; + if (authState !== 'authenticating') { + onBack(); + } + } else if (key.name === 'return') { + if (authState === 'idle') { + void runAuthentication(); + } else if (authState === 'success' || authState === 'error') { + onBack(); } } }, { isActive: true }, ); - const handleAuthenticate = async () => { - if (!server) return; - - setIsAuthenticating(true); - setAuthError(null); - - try { - // TODO: 实现 OAuth 认证逻辑 - // 这里需要调用 MCPOAuthProvider 进行认证 - // 认证成功后调用 onSuccess() - // 认证失败时设置 authError - - // 临时实现:显示提示信息 - setAuthError(t('OAuth authentication is not yet implemented')); - } catch (error) { - setAuthError( - error instanceof Error ? error.message : t('Authentication failed'), - ); - } finally { - setIsAuthenticating(false); - } - }; - if (!server) { return ( @@ -91,72 +127,51 @@ export const AuthenticateStep: React.FC = ({ ); } - const oauthConfig = getOAuthConfigFromServerConfig(server.config); - const hasOAuth = !!oauthConfig; - return ( - {/* 认证说明 */} - - - {t('OAuth Authentication')} + {/* Server info */} + + + {t('Server:')} {server.name} - - - - {t('Server:')} {server.name} - - - - {!hasOAuth && ( - - - {t('This server does not have OAuth configuration.')} - - - )} - - {authError && ( - - {authError} - - )} - {/* 操作列表 */} - {!hasOAuth ? ( - - - items={actions.filter((a) => a.key === 'back')} - onHighlight={(value: AuthAction) => setSelectedAction(value)} - onSelect={(value: AuthAction) => { - if (value === 'back') { - onBack(); - } - }} - /> - - ) : ( - - - items={actions} - onHighlight={(value: AuthAction) => setSelectedAction(value)} - onSelect={(value: AuthAction) => { - if (value === 'back') { - onBack(); - } - }} - /> + {/* Progress messages */} + {messages.length > 0 && ( + + {messages.map((msg, i) => ( + + {msg} + + ))} )} - {isAuthenticating && ( + {/* Error message */} + {authState === 'error' && errorMessage && ( - - {t('Authenticating... Please wait.')} - + {errorMessage} )} + + {/* Action hints */} + + {authState === 'idle' && ( + + {t('Press Enter to start authentication, Esc to go back')} + + )} + {authState === 'authenticating' && ( + + {t('Authenticating... Please complete the login in your browser.')} + + )} + {(authState === 'success' || authState === 'error') && ( + + {t('Press Enter or Esc to go back')} + + )} + ); }; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx index 13d72542c..56acd2e8d 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useEffect } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; @@ -37,7 +37,11 @@ export const ServerDetailStep: React.FC = ({ const [selectedAction, setSelectedAction] = useState('view-tools'); - const statusColor = server ? getStatusColor(server.status) : 'gray'; + const statusColor = server + ? server.isDisabled + ? 'yellow' + : getStatusColor(server.status) + : 'gray'; // 根据服务器状态动态生成可用操作 const actions = useMemo(() => { @@ -76,9 +80,8 @@ export const ServerDetailStep: React.FC = ({ value: 'toggle-disable', }); - // 如果服务器配置了 OAuth,显示认证选项 - if (server && !server.isDisabled) { - // TODO: 检查服务器是否有 OAuth 配置 + // 只在服务器配置了 OAuth 时显示认证选项 + if (!server.isDisabled && server.config.oauth?.enabled) { result.push({ key: 'authenticate', label: t('Authenticate'), @@ -89,6 +92,10 @@ export const ServerDetailStep: React.FC = ({ return result; }, [server]); + useEffect(() => { + setSelectedAction(server?.isDisabled ? 'toggle-disable' : 'view-tools'); + }, [server?.isDisabled]); + useKeypress( (key) => { if (key.name === 'escape') { @@ -141,10 +148,8 @@ export const ServerDetailStep: React.FC = ({ : theme.status.error } > - {getStatusIcon(server.status)} {t(server.status)} - {server.isDisabled && ( - {t('(disabled)')} - )} + {getStatusIcon(server.status)}{' '} + {server.isDisabled ? t('disabled') : t(server.status)} @@ -154,7 +159,7 @@ export const ServerDetailStep: React.FC = ({ {t('Source:')} - + {server.scope === 'user' ? t('User Settings') : server.scope === 'workspace' @@ -184,37 +189,29 @@ export const ServerDetailStep: React.FC = ({ )} - - - {t('Capabilities:')} - + {!server.isDisabled && ( - - {server.toolCount > 0 ? t('tools') : ''} - {server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''} - {server.promptCount > 0 ? t('prompts') : ''} - + + {t('Tools:')} + + + + {server.toolCount}{' '} + {server.toolCount === 1 ? t('tool') : t('tools')} + {!!server.invalidToolCount && server.invalidToolCount > 0 && ( + + {' '} + ({server.invalidToolCount}{' '} + {server.invalidToolCount === 1 + ? t('invalid') + : t('invalid')} + ) + + )} + + - - - - - {t('Tools:')} - - - - {server.toolCount}{' '} - {server.toolCount === 1 ? t('tool') : t('tools')} - {!!server.invalidToolCount && server.invalidToolCount > 0 && ( - - {' '} - ({server.invalidToolCount}{' '} - {server.invalidToolCount === 1 ? t('invalid') : t('invalid')}) - - )} - - - + )} {server.errorMessage && ( @@ -234,7 +231,10 @@ export const ServerDetailStep: React.FC = ({ items={actions} - onHighlight={(value: ServerAction) => setSelectedAction(value)} + showNumbers={false} + onHighlight={(value: ServerAction) => { + setSelectedAction(value); + }} onSelect={(value: ServerAction) => { switch (value) { case 'view-tools': diff --git a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx index d2f12b237..ad9e0b6f3 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx @@ -90,11 +90,22 @@ export const ServerListStep: React.FC = ({ return ( + {/* 服务器统计 */} + + + {servers.length} {servers.length === 1 ? t('server') : t('servers')} + + + {/* 分组服务器列表 */} {groupedServers.map((group, groupIndex) => ( - + - {group.displayName} + {` ${group.displayName}`} {group.servers[0]?.configPath && ( {' '} @@ -107,7 +118,9 @@ export const ServerListStep: React.FC = ({ const isSelected = groupIndex === currentPosition.groupIndex && itemIndex === currentPosition.itemIndex; - const statusColor = getStatusColor(server.status); + const statusColor = server.isDisabled + ? 'yellow' + : getStatusColor(server.status); return ( @@ -142,13 +155,9 @@ export const ServerListStep: React.FC = ({ : theme.status.error } > - {getStatusIcon(server.status)} {t(server.status)} + {getStatusIcon(server.status)}{' '} + {server.isDisabled ? t('disabled') : t(server.status)} - {/* 显示 Scope 和禁用状态 */} - [{server.scope}] - {server.isDisabled && ( - {t('(disabled)')} - )} {/* 显示无效工具警告 */} {!!server.invalidToolCount && server.invalidToolCount > 0 && ( @@ -166,7 +175,7 @@ export const ServerListStep: React.FC = ({ ))} {/* 提示信息 */} - {servers.some((s) => s.status === 'disconnected') && ( + {servers.some((s) => s.status === 'disconnected' && !s.isDisabled) && ( ※ {t('Run qwen --debug to see error logs')} diff --git a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx index 1d02a0f10..b66a0a58f 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx @@ -78,10 +78,10 @@ export const ToolListStep: React.FC = ({ 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')); + if (tool.annotations?.destructiveHint) hints.push('destructive'); + if (tool.annotations?.readOnlyHint) hints.push('read-only'); + if (tool.annotations?.openWorldHint) hints.push('open-world'); + if (tool.annotations?.idempotentHint) hints.push('idempotent'); return hints.join(', '); };