fix(mcp): Display OAuth authentication messages in CLI UI instead of debug console (#6919)

Co-authored-by: Yoichiro Tanaka <yoichiro6642@gmail.com>
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
Sarah Price
2025-09-18 00:25:33 +02:00
committed by GitHub
parent eddd13d70e
commit d54cdd8802
4 changed files with 51 additions and 38 deletions
@@ -904,6 +904,7 @@ describe('mcpCommand', () => {
'test-server', 'test-server',
{ enabled: true }, { enabled: true },
'http://localhost:3000', 'http://localhost:3000',
expect.any(Object),
); );
expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith( expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith(
'test-server', 'test-server',
+15 -2
View File
@@ -22,6 +22,7 @@ import {
getErrorMessage, getErrorMessage,
MCPOAuthTokenStorage, MCPOAuthTokenStorage,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { appEvents, AppEvent } from '../../utils/events.js';
const COLOR_GREEN = '\u001b[32m'; const COLOR_GREEN = '\u001b[32m';
const COLOR_YELLOW = '\u001b[33m'; const COLOR_YELLOW = '\u001b[33m';
@@ -368,6 +369,12 @@ const authCommand: SlashCommand = {
// Always attempt OAuth authentication, even if not explicitly configured // Always attempt OAuth authentication, even if not explicitly configured
// The authentication process will discover OAuth requirements automatically // The authentication process will discover OAuth requirements automatically
const displayListener = (message: string) => {
context.ui.addItem({ type: 'info', text: message }, Date.now());
};
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
try { try {
context.ui.addItem( context.ui.addItem(
{ {
@@ -385,10 +392,14 @@ const authCommand: SlashCommand = {
oauthConfig = { enabled: false }; oauthConfig = { enabled: false };
} }
// Pass the MCP server URL for OAuth discovery
const mcpServerUrl = server.httpUrl || server.url; const mcpServerUrl = server.httpUrl || server.url;
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
await authProvider.authenticate(serverName, oauthConfig, mcpServerUrl); await authProvider.authenticate(
serverName,
oauthConfig,
mcpServerUrl,
appEvents,
);
context.ui.addItem( context.ui.addItem(
{ {
@@ -430,6 +441,8 @@ const authCommand: SlashCommand = {
messageType: 'error', messageType: 'error',
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`, content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
}; };
} finally {
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
} }
}, },
completion: async (context: CommandContext, partialArg: string) => { completion: async (context: CommandContext, partialArg: string) => {
+1
View File
@@ -9,6 +9,7 @@ import { EventEmitter } from 'node:events';
export enum AppEvent { export enum AppEvent {
OpenDebugConsole = 'open-debug-console', OpenDebugConsole = 'open-debug-console',
LogError = 'log-error', LogError = 'log-error',
OauthDisplayMessage = 'oauth-display-message',
} }
export const appEvents = new EventEmitter(); export const appEvents = new EventEmitter();
+34 -36
View File
@@ -7,12 +7,15 @@
import * as http from 'node:http'; import * as http from 'node:http';
import * as crypto from 'node:crypto'; import * as crypto from 'node:crypto';
import { URL } from 'node:url'; import { URL } from 'node:url';
import type { EventEmitter } from 'node:events';
import { openBrowserSecurely } from '../utils/secure-browser-launcher.js'; import { openBrowserSecurely } from '../utils/secure-browser-launcher.js';
import type { OAuthToken } from './token-storage/types.js'; import type { OAuthToken } from './token-storage/types.js';
import { MCPOAuthTokenStorage } from './oauth-token-storage.js'; import { MCPOAuthTokenStorage } from './oauth-token-storage.js';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import { OAuthUtils } from './oauth-utils.js'; import { OAuthUtils } from './oauth-utils.js';
export const OAUTH_DISPLAY_MESSAGE_EVENT = 'oauth-display-message' as const;
/** /**
* OAuth configuration for an MCP server. * OAuth configuration for an MCP server.
*/ */
@@ -575,18 +578,28 @@ export class MCPOAuthProvider {
* @param serverName The name of the MCP server * @param serverName The name of the MCP server
* @param config OAuth configuration * @param config OAuth configuration
* @param mcpServerUrl Optional MCP server URL for OAuth discovery * @param mcpServerUrl Optional MCP server URL for OAuth discovery
* @param messageHandler Optional handler for displaying user-facing messages
* @returns The obtained OAuth token * @returns The obtained OAuth token
*/ */
async authenticate( async authenticate(
serverName: string, serverName: string,
config: MCPOAuthConfig, config: MCPOAuthConfig,
mcpServerUrl?: string, mcpServerUrl?: string,
events?: EventEmitter,
): Promise<OAuthToken> { ): Promise<OAuthToken> {
// Helper function to display messages through handler or fallback to console.log
const displayMessage = (message: string) => {
if (events) {
events.emit(OAUTH_DISPLAY_MESSAGE_EVENT, message);
} else {
console.log(message);
}
};
// If no authorization URL is provided, try to discover OAuth configuration // If no authorization URL is provided, try to discover OAuth configuration
if (!config.authorizationUrl && mcpServerUrl) { if (!config.authorizationUrl && mcpServerUrl) {
console.log( console.debug(`Starting OAuth for MCP server "${serverName}"…
'No authorization URL provided, attempting OAuth discovery...', No authorization URL; using OAuth discovery`);
);
// First check if the server requires authentication via WWW-Authenticate header // First check if the server requires authentication via WWW-Authenticate header
try { try {
@@ -662,9 +675,7 @@ export class MCPOAuthProvider {
const authUrl = new URL(config.authorizationUrl); const authUrl = new URL(config.authorizationUrl);
const serverUrl = `${authUrl.protocol}//${authUrl.host}`; const serverUrl = `${authUrl.protocol}//${authUrl.host}`;
console.log( console.debug('→ Attempting dynamic client registration...');
'No client ID provided, attempting dynamic client registration...',
);
// Get the authorization server metadata for registration // Get the authorization server metadata for registration
const authServerMetadataUrl = new URL( const authServerMetadataUrl = new URL(
@@ -694,7 +705,7 @@ export class MCPOAuthProvider {
config.clientSecret = clientRegistration.client_secret; config.clientSecret = clientRegistration.client_secret;
} }
console.log('Dynamic client registration successful'); console.debug('Dynamic client registration successful');
} else { } else {
throw new Error( throw new Error(
'No client ID provided and dynamic registration not supported', 'No client ID provided and dynamic registration not supported',
@@ -719,30 +730,13 @@ export class MCPOAuthProvider {
mcpServerUrl, mcpServerUrl,
); );
console.log('\nOpening browser for OAuth authentication...'); displayMessage(`Opening your browser for OAuth sign-in...
console.log('If the browser does not open, please visit:');
console.log('');
// Get terminal width or default to 80 If the browser does not open, copy and paste this URL into your browser:
const terminalWidth = process.stdout.columns || 80; ${authUrl}
const separatorLength = Math.min(terminalWidth - 2, 80);
const separator = '━'.repeat(separatorLength);
console.log(separator); 💡 TIP: Triple-click to select the entire URL, then copy and paste it into your browser.
console.log( ⚠️ Make sure to copy the COMPLETE URL - it may wrap across multiple lines.`);
'COPY THE ENTIRE URL BELOW (select all text between the lines):',
);
console.log(separator);
console.log(authUrl);
console.log(separator);
console.log('');
console.log(
'💡 TIP: Triple-click to select the entire URL, then copy and paste it into your browser.',
);
console.log(
'⚠️ Make sure to copy the COMPLETE URL - it may wrap across multiple lines.',
);
console.log('');
// Start callback server // Start callback server
const callbackPromise = this.startCallbackServer(pkceParams.state); const callbackPromise = this.startCallbackServer(pkceParams.state);
@@ -760,7 +754,7 @@ export class MCPOAuthProvider {
// Wait for callback // Wait for callback
const { code } = await callbackPromise; const { code } = await callbackPromise;
console.log('\nAuthorization code received, exchanging for tokens...'); console.debug('Authorization code received, exchanging for tokens...');
// Exchange code for tokens // Exchange code for tokens
const tokenResponse = await this.exchangeCodeForToken( const tokenResponse = await this.exchangeCodeForToken(
@@ -795,16 +789,20 @@ export class MCPOAuthProvider {
config.tokenUrl, config.tokenUrl,
mcpServerUrl, mcpServerUrl,
); );
console.log('Authentication successful! Token saved.'); console.debug('Authentication successful! Token saved.');
// Verify token was saved // Verify token was saved
const savedToken = await this.tokenStorage.getCredentials(serverName); const savedToken = await this.tokenStorage.getCredentials(serverName);
if (savedToken && savedToken.token && savedToken.token.accessToken) { if (savedToken && savedToken.token && savedToken.token.accessToken) {
const tokenPreview = // Avoid leaking token material; log a short SHA-256 fingerprint instead.
savedToken.token.accessToken.length > 20 const tokenFingerprint = crypto
? `${savedToken.token.accessToken.substring(0, 20)}...` .createHash('sha256')
: '[token]'; .update(savedToken.token.accessToken)
console.log(`Token verification successful: ${tokenPreview}`); .digest('hex')
.slice(0, 8);
console.debug(
`✓ Token verification successful (fingerprint: ${tokenFingerprint})`,
);
} else { } else {
console.error( console.error(
'Token verification failed: token not found or invalid after save', 'Token verification failed: token not found or invalid after save',