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

@ -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;
}