fix: 重写 AuthenticateStep 使用正确的 OAuth 流程

- 按照 mcpCommand.ts 中的 auth 实现重写 AuthenticateStep
- 使用 MCPOAuthProvider + MCPOAuthTokenStorage 进行实际 OAuth 认证
- 通过 appEvents/AppEvent.OauthDisplayMessage 展示认证过程消息
- 认证成功后自动触发 tool re-discovery 和 client tools 更新
- 只在 server.config.oauth?.enabled 为 true 时显示 Authenticate 选项
- 认证成功后 reload servers 刷新列表
This commit is contained in:
LaZzyMan 2026-03-09 12:05:27 +08:00
parent 8c693ba738
commit 9e34b5db4a
5 changed files with 192 additions and 168 deletions

View file

@ -529,8 +529,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
<AuthenticateStep
server={selectedServer}
onSuccess={() => {
// TODO: 认证成功后重新加载服务器列表
handleNavigateBack();
void reloadServers();
}}
onBack={handleNavigateBack}
/>
@ -558,6 +557,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
handleSelectTool,
handleSelectDisableScope,
getServerTools,
reloadServers,
]);
// Render step footer

View file

@ -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<AuthenticateStepProps> = ({
server,
onSuccess,
onBack,
}) => {
const [selectedAction, setSelectedAction] = useState<AuthAction>('back');
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [authError, setAuthError] = useState<string | null>(null);
const config = useConfig();
const [authState, setAuthState] = useState<AuthState>('idle');
const [messages, setMessages] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<Box>
@ -91,72 +127,51 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
);
}
const oauthConfig = getOAuthConfigFromServerConfig(server.config);
const hasOAuth = !!oauthConfig;
return (
<Box flexDirection="column" gap={1}>
{/* 认证说明 */}
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
{t('OAuth Authentication')}
{/* Server info */}
<Box>
<Text color={theme.text.secondary}>
{t('Server:')} {server.name}
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Server:')} {server.name}
</Text>
</Box>
{!hasOAuth && (
<Box marginTop={1}>
<Text color={theme.status.warning}>
{t('This server does not have OAuth configuration.')}
</Text>
</Box>
)}
{authError && (
<Box marginTop={1}>
<Text color={theme.status.error}>{authError}</Text>
</Box>
)}
</Box>
{/* 操作列表 */}
{!hasOAuth ? (
<Box>
<RadioButtonSelect<AuthAction>
items={actions.filter((a) => a.key === 'back')}
onHighlight={(value: AuthAction) => setSelectedAction(value)}
onSelect={(value: AuthAction) => {
if (value === 'back') {
onBack();
}
}}
/>
</Box>
) : (
<Box>
<RadioButtonSelect<AuthAction>
items={actions}
onHighlight={(value: AuthAction) => setSelectedAction(value)}
onSelect={(value: AuthAction) => {
if (value === 'back') {
onBack();
}
}}
/>
{/* Progress messages */}
{messages.length > 0 && (
<Box flexDirection="column">
{messages.map((msg, i) => (
<Text key={i} color={theme.text.secondary}>
{msg}
</Text>
))}
</Box>
)}
{isAuthenticating && (
{/* Error message */}
{authState === 'error' && errorMessage && (
<Box>
<Text color={theme.text.secondary}>
{t('Authenticating... Please wait.')}
</Text>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
{/* Action hints */}
<Box>
{authState === 'idle' && (
<Text color={theme.text.secondary}>
{t('Press Enter to start authentication, Esc to go back')}
</Text>
)}
{authState === 'authenticating' && (
<Text color={theme.text.secondary}>
{t('Authenticating... Please complete the login in your browser.')}
</Text>
)}
{(authState === 'success' || authState === 'error') && (
<Text color={theme.text.secondary}>
{t('Press Enter or Esc to go back')}
</Text>
)}
</Box>
</Box>
);
};

View file

@ -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<ServerDetailStepProps> = ({
const [selectedAction, setSelectedAction] =
useState<ServerAction>('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<ServerDetailStepProps> = ({
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<ServerDetailStepProps> = ({
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<ServerDetailStepProps> = ({
: theme.status.error
}
>
{getStatusIcon(server.status)} {t(server.status)}
{server.isDisabled && (
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
)}
{getStatusIcon(server.status)}{' '}
{server.isDisabled ? t('disabled') : t(server.status)}
</Text>
</Box>
</Box>
@ -154,7 +159,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
<Text color={theme.text.primary}>{t('Source:')}</Text>
</Box>
<Box>
<Text color={theme.text.secondary}>
<Text color={theme.text.primary}>
{server.scope === 'user'
? t('User Settings')
: server.scope === 'workspace'
@ -184,37 +189,29 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
</Box>
)}
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Capabilities:')}</Text>
</Box>
{!server.isDisabled && (
<Box>
<Text>
{server.toolCount > 0 ? t('tools') : ''}
{server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''}
{server.promptCount > 0 ? t('prompts') : ''}
</Text>
<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>
</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>
@ -234,7 +231,10 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
<Box>
<RadioButtonSelect<ServerAction>
items={actions}
onHighlight={(value: ServerAction) => setSelectedAction(value)}
showNumbers={false}
onHighlight={(value: ServerAction) => {
setSelectedAction(value);
}}
onSelect={(value: ServerAction) => {
switch (value) {
case 'view-tools':

View file

@ -90,11 +90,22 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
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}>
<Box
key={group.source}
flexDirection="column"
marginBottom={groupIndex === groupedServers.length - 1 ? 0 : 1}
>
<Text bold color={theme.text.primary}>
{group.displayName}
{` ${group.displayName}`}
{group.servers[0]?.configPath && (
<Text color={theme.text.secondary}>
{' '}
@ -107,7 +118,9 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
const isSelected =
groupIndex === currentPosition.groupIndex &&
itemIndex === currentPosition.itemIndex;
const statusColor = getStatusColor(server.status);
const statusColor = server.isDisabled
? 'yellow'
: getStatusColor(server.status);
return (
<Box key={server.name}>
@ -142,13 +155,9 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
: theme.status.error
}
>
{getStatusIcon(server.status)} {t(server.status)}
{getStatusIcon(server.status)}{' '}
{server.isDisabled ? t('disabled') : 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}>
@ -166,7 +175,7 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
))}
{/* 提示信息 */}
{servers.some((s) => s.status === 'disconnected') && (
{servers.some((s) => s.status === 'disconnected' && !s.isDisabled) && (
<Box>
<Text color={theme.status.warning}>
{t('Run qwen --debug to see error logs')}

View file

@ -78,10 +78,10 @@ export const ToolListStep: React.FC<ToolListStepProps> = ({
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(', ');
};