mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 12:40:44 +00:00
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:
parent
8c693ba738
commit
9e34b5db4a
5 changed files with 192 additions and 168 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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(', ');
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue