feat(mcp): improve OAuth auth UX - post-auth feedback, i18n, clear auth, and bug fixes

- Show tool count and completion message after successful authentication
- Auto-navigate back to server details after auth success (2s delay)
- Add structured i18n messages for OAuth display (core emits key/params, CLI translates)
- Add 'Clear Authentication' option for servers with stored OAuth tokens
- Fix: clearing auth now disconnects server (not just deleting tokens)
- Fix: auth popup infinite loop caused by onSuccess triggering reload/remount cycle
- Add ToolRegistry.disconnectServer() (disconnect without adding to exclusion list)
- Add i18n translations for all 6 languages (en/zh/de/ja/pt/ru)
This commit is contained in:
LaZzyMan 2026-03-12 20:46:59 +08:00
parent 17581c1e8a
commit dcf986838c
13 changed files with 213 additions and 21 deletions

View file

@ -745,6 +745,15 @@ export default {
"Authentifizierung mit MCP-Server '{{name}}' fehlgeschlagen: {{error}}",
"Re-discovering tools from '{{name}}'...":
"Werkzeuge von '{{name}}' werden neu erkannt...",
"Discovered {{count}} tool(s) from '{{name}}'.":
"{{count}} Werkzeug(e) von '{{name}}' entdeckt.",
'Authentication complete. Returning to server details...':
'Authentifizierung abgeschlossen. Zurück zu den Serverdetails...',
'Authentication successful.': 'Authentifizierung erfolgreich.',
'If the browser does not open, copy and paste this URL into your browser:':
'Falls der Browser sich nicht öffnet, kopieren Sie diese URL und fügen Sie sie in Ihren Browser ein:',
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
'⚠️ Stellen Sie sicher, dass Sie die VOLLSTÄNDIGE URL kopieren sie kann über mehrere Zeilen gehen.',
// ============================================================================
// Commands - Chat
@ -916,6 +925,7 @@ export default {
Disable: 'Deaktivieren',
Enable: 'Aktivieren',
Authenticate: 'Authentifizieren',
'Clear Authentication': 'Authentifizierung löschen',
disabled: 'deaktiviert',
'Server:': 'Server:',
Reconnect: 'Neu verbinden',

View file

@ -811,6 +811,15 @@ export default {
"Failed to authenticate with MCP server '{{name}}': {{error}}",
"Re-discovering tools from '{{name}}'...":
"Re-discovering tools from '{{name}}'...",
"Discovered {{count}} tool(s) from '{{name}}'.":
"Discovered {{count}} tool(s) from '{{name}}'.",
'Authentication complete. Returning to server details...':
'Authentication complete. Returning to server details...',
'Authentication successful.': 'Authentication successful.',
'If the browser does not open, copy and paste this URL into your browser:':
'If the browser does not open, copy and paste this URL into your browser:',
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.',
// ============================================================================
// MCP Management Dialog
@ -843,6 +852,7 @@ export default {
Enable: 'Enable',
Disable: 'Disable',
Authenticate: 'Authenticate',
'Clear Authentication': 'Clear Authentication',
'Server:': 'Server:',
'Command:': 'Command:',
'Working Directory:': 'Working Directory:',

View file

@ -507,6 +507,15 @@ export default {
"MCPサーバー '{{name}}' での認証に失敗: {{error}}",
"Re-discovering tools from '{{name}}'...":
"'{{name}}' からツールを再検出中...",
"Discovered {{count}} tool(s) from '{{name}}'.":
"'{{name}}' から {{count}} 個のツールを検出しました。",
'Authentication complete. Returning to server details...':
'認証完了。サーバー詳細に戻ります...',
'Authentication successful.': '認証成功。',
'If the browser does not open, copy and paste this URL into your browser:':
'ブラウザが開かない場合は、このURLをコピーしてブラウザに貼り付けてください',
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
'⚠️ URL全体をコピーしてください——複数行にまたがる場合があります。',
'Configured MCP servers:': '設定済みMCPサーバー:',
Ready: '準備完了',
Disconnected: '切断',
@ -655,6 +664,7 @@ export default {
Disable: '無効化',
Enable: '有効化',
Authenticate: '認証',
'Clear Authentication': '認証をクリア',
disabled: '無効',
'Server:': 'サーバー:',
Reconnect: '再接続',

View file

@ -751,6 +751,15 @@ export default {
"Falha ao autenticar com o servidor MCP '{{name}}': {{error}}",
"Re-discovering tools from '{{name}}'...":
"Redescobrindo ferramentas de '{{name}}'...",
"Discovered {{count}} tool(s) from '{{name}}'.":
"{{count}} ferramenta(s) descoberta(s) de '{{name}}'.",
'Authentication complete. Returning to server details...':
'Autenticação concluída. Retornando aos detalhes do servidor...',
'Authentication successful.': 'Autenticação bem-sucedida.',
'If the browser does not open, copy and paste this URL into your browser:':
'Se o navegador não abrir, copie e cole esta URL no seu navegador:',
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
'⚠️ Certifique-se de copiar a URL COMPLETA ela pode ocupar várias linhas.',
// ============================================================================
// Commands - Chat
@ -922,6 +931,7 @@ export default {
Disable: 'Desativar',
Enable: 'Ativar',
Authenticate: 'Autenticar',
'Clear Authentication': 'Limpar autenticação',
disabled: 'desativado',
'Server:': 'Servidor:',
Reconnect: 'Reconectar',

View file

@ -754,6 +754,15 @@ export default {
"Не удалось авторизоваться на MCP-сервере '{{name}}': {{error}}",
"Re-discovering tools from '{{name}}'...":
"Повторное обнаружение инструментов от '{{name}}'...",
"Discovered {{count}} tool(s) from '{{name}}'.":
"Обнаружено {{count}} инструмент(ов) от '{{name}}'.",
'Authentication complete. Returning to server details...':
'Аутентификация завершена. Возврат к деталям сервера...',
'Authentication successful.': 'Аутентификация успешна.',
'If the browser does not open, copy and paste this URL into your browser:':
'Если браузер не открылся, скопируйте этот URL и вставьте его в браузер:',
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
'⚠️ Убедитесь, что скопировали ПОЛНЫЙ URL — он может занимать несколько строк.',
// ============================================================================
// Команды - Чат
@ -900,6 +909,7 @@ export default {
Disable: 'Отключить',
Enable: 'Включить',
Authenticate: 'Аутентификация',
'Clear Authentication': 'Очистить аутентификацию',
disabled: 'отключен',
'Server:': 'Сервер:',
Reconnect: 'Переподключить',

View file

@ -763,6 +763,15 @@ export default {
"认证 MCP 服务器 '{{name}}' 失败:{{error}}",
"Re-discovering tools from '{{name}}'...":
"正在重新发现 '{{name}}' 的工具...",
"Discovered {{count}} tool(s) from '{{name}}'.":
"从 '{{name}}' 发现了 {{count}} 个工具。",
'Authentication complete. Returning to server details...':
'认证完成,正在返回服务器详情...',
'Authentication successful.': '认证成功。',
'If the browser does not open, copy and paste this URL into your browser:':
'如果浏览器未自动打开,请复制以下 URL 并粘贴到浏览器中:',
'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.':
'⚠️ 请确保复制完整的 URL —— 它可能跨越多行。',
// ============================================================================
// MCP Management Dialog
@ -793,6 +802,7 @@ export default {
Enable: '启用',
Disable: '禁用',
Authenticate: '认证',
'Clear Authentication': '清空认证',
disabled: '已禁用',
'Server:': '服务器:',
'(disabled)': '(已禁用)',

View file

@ -25,6 +25,7 @@ import { useConfig } from '../../contexts/ConfigContext.js';
import {
getMCPServerStatus,
DiscoveredMCPTool,
MCPOAuthTokenStorage,
type MCPServerConfig,
type AnyDeclarativeTool,
type DiscoveredMCPPrompt,
@ -109,6 +110,16 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
(t) => !t.name || !t.description,
).length;
// Check if OAuth tokens exist for this server
let hasOAuthTokens = false;
try {
const tokenStorage = new MCPOAuthTokenStorage();
const credentials = await tokenStorage.getCredentials(name);
hasOAuthTokens = credentials !== null;
} catch {
// Ignore errors when checking token existence
}
serverInfos.push({
name,
status,
@ -118,6 +129,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
invalidToolCount,
promptCount: serverPrompts.length,
isDisabled,
hasOAuthTokens,
});
}
@ -249,6 +261,36 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
}
}, [fetchServerData]);
// Clear OAuth authentication tokens and disconnect the server
const handleClearAuth = useCallback(async () => {
if (!config || !selectedServer) return;
try {
setIsLoading(true);
const tokenStorage = new MCPOAuthTokenStorage();
await tokenStorage.deleteCredentials(selectedServer.name);
debugLogger.info(
`Cleared OAuth tokens for server '${selectedServer.name}'`,
);
// Disconnect the server so it no longer appears as connected
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
await toolRegistry.disconnectServer(selectedServer.name);
}
// Reload to update hasOAuthTokens flag and server status
await reloadServers();
} catch (error) {
debugLogger.error(
`Error clearing OAuth tokens for server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
}, [config, selectedServer, reloadServers]);
// Reconnect server
const handleReconnect = useCallback(async () => {
if (!config || !selectedServer) return;
@ -537,6 +579,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
onReconnect={handleReconnect}
onDisable={handleDisable}
onAuthenticate={handleAuthenticate}
onClearAuth={handleClearAuth}
onBack={handleNavigateBack}
/>
);
@ -569,10 +612,10 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
return (
<AuthenticateStep
server={selectedServer}
onSuccess={() => {
onBack={() => {
handleNavigateBack();
void reloadServers();
}}
onBack={handleNavigateBack}
/>
);
@ -594,6 +637,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
handleReconnect,
handleDisable,
handleAuthenticate,
handleClearAuth,
handleNavigateBack,
handleSelectTool,
handleSelectDisableScope,

View file

@ -16,13 +16,15 @@ import {
MCPOAuthTokenStorage,
getErrorMessage,
} from '@qwen-code/qwen-code-core';
import type { OAuthDisplayPayload } from '@qwen-code/qwen-code-core';
import { appEvents, AppEvent } from '../../../../utils/events.js';
type AuthState = 'idle' | 'authenticating' | 'success' | 'error';
const AUTO_BACK_DELAY_MS = 2000;
export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
server,
onSuccess,
onBack,
}) => {
const config = useConfig();
@ -39,9 +41,12 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
setMessages([]);
setErrorMessage(null);
// Listen for OAuth display messages (same as mcpCommand.ts)
const displayListener = (message: string) => {
setMessages((prev) => [...prev, message]);
// Listen for OAuth display messages - supports both plain strings and
// structured i18n messages ({ key, params }) emitted by the core layer.
const displayListener = (message: OAuthDisplayPayload) => {
const text =
typeof message === 'string' ? message : t(message.key, message.params);
setMessages((prev) => [...prev, text]);
};
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
@ -83,6 +88,16 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
}),
]);
await toolRegistry.discoverToolsForServer(server.name);
// Show discovered tool count
const discoveredTools = toolRegistry.getToolsByServer(server.name);
setMessages((prev) => [
...prev,
t("Discovered {{count}} tool(s) from '{{name}}'.", {
count: String(discoveredTools.length),
name: server.name,
}),
]);
}
// Update the client with the new tools
@ -91,8 +106,12 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
await geminiClient.setTools();
}
setMessages((prev) => [
...prev,
t('Authentication complete. Returning to server details...'),
]);
setAuthState('success');
onSuccess?.();
} catch (error) {
setErrorMessage(getErrorMessage(error));
setAuthState('error');
@ -100,13 +119,22 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
isRunning.current = false;
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
}
}, [server, config, onSuccess]);
}, [server, config]);
useEffect(() => {
runAuthentication();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-navigate back after authentication succeeds
useEffect(() => {
if (authState !== 'success') return;
const timer = setTimeout(() => {
onBack();
}, AUTO_BACK_DELAY_MS);
return () => clearTimeout(timer);
}, [authState, onBack]);
useKeypress(
(key) => {
if (key.name === 'escape') {
@ -158,6 +186,11 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
{t('Authenticating... Please complete the login in your browser.')}
</Text>
)}
{authState === 'success' && (
<Text color={theme.status.success}>
{t('Authentication successful.')}
</Text>
)}
</Box>
</Box>
);

View file

@ -24,7 +24,8 @@ type ServerAction =
| 'view-tools'
| 'reconnect'
| 'toggle-disable'
| 'authenticate';
| 'authenticate'
| 'clear-auth';
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
server,
@ -32,6 +33,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
onReconnect,
onDisable,
onAuthenticate,
onClearAuth,
onBack,
}) => {
const statusColor = server
@ -86,6 +88,15 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
});
}
// 只在存储有 OAuth 认证信息时显示“清空认证”选项
if (!server.isDisabled && server.hasOAuthTokens) {
result.push({
key: 'clear-auth',
label: t('Clear Authentication'),
value: 'clear-auth',
});
}
return result;
}, [server]);
@ -222,6 +233,9 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
case 'authenticate':
onAuthenticate?.();
break;
case 'clear-auth':
onClearAuth?.();
break;
default:
break;
}

View file

@ -48,6 +48,8 @@ export interface MCPServerDisplayInfo {
errorMessage?: string;
/** 是否被禁用(在排除列表中) */
isDisabled: boolean;
/** 是否存储有 OAuth 认证信息 */
hasOAuthTokens?: boolean;
}
/**
@ -132,6 +134,8 @@ export interface ServerDetailStepProps {
onDisable?: () => void;
/** OAuth 认证回调 */
onAuthenticate?: () => void;
/** 清空认证信息回调 */
onClearAuth?: () => void;
/** 返回回调 */
onBack: () => void;
}
@ -178,8 +182,6 @@ export interface ToolDetailStepProps {
export interface AuthenticateStepProps {
/** 服务器信息 */
server: MCPServerDisplayInfo | null;
/** 认证成功回调 */
onSuccess?: () => void;
/** 返回回调 */
onBack: () => void;
}