diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 0a30fcd4f08..1fbe3394860 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1083,6 +1083,12 @@ export default { 'Press Enter to start authentication, Esc to go back', 'Authenticating... Please complete the login in your browser.': 'Authenticating... Please complete the login in your browser.', + 'Press c to copy the authorization URL to your clipboard.': + 'Press c to copy the authorization URL to your clipboard.', + 'Copy request sent to your terminal. If paste is empty, copy the URL above manually.': + 'Copy request sent to your terminal. If paste is empty, copy the URL above manually.', + 'Cannot write to terminal — copy the URL above manually.': + 'Cannot write to terminal — copy the URL above manually.', 'Press Enter or Esc to go back': 'Press Enter or Esc to go back', // MCP Tool List diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index c6e769916e1..3f4de6d6030 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1023,6 +1023,12 @@ export default { '按 Enter 开始认证,Esc 返回', 'Authenticating... Please complete the login in your browser.': '认证中... 请在浏览器中完成登录。', + 'Press c to copy the authorization URL to your clipboard.': + '按 c 复制授权 URL 到剪贴板。', + 'Copy request sent to your terminal. If paste is empty, copy the URL above manually.': + '已向终端发送复制请求;若粘贴为空,请手动复制上方 URL。', + 'Cannot write to terminal — copy the URL above manually.': + '无法写入终端,请手动复制上方 URL。', 'Press Enter or Esc to go back': '按 Enter 或 Esc 返回', // MCP Server Detail diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx index 6e0011a77f8..42b82957f84 100644 --- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -22,6 +22,47 @@ import { appEvents, AppEvent } from '../../../../utils/events.js'; type AuthState = 'idle' | 'authenticating' | 'success' | 'error'; const AUTO_BACK_DELAY_MS = 2000; +const COPY_FEEDBACK_MS = 2000; + +/** + * Wrap an OSC sequence for terminal multiplexers so the host terminal + * receives it. tmux requires a DCS passthrough with inner ESCs doubled; + * GNU screen uses a plain DCS envelope. + */ +function wrapForMultiplexer(osc: string): string { + if (process.env['TMUX']) { + return `\x1bPtmux;${osc.split('\x1b').join('\x1b\x1b')}\x1b\\`; + } + if (process.env['STY']) { + return `\x1bP${osc}\x1b\\`; + } + return osc; +} + +/** + * Copy a string to the user's clipboard using the OSC 52 terminal escape + * sequence. Works through SSH and most web terminals (iTerm2, Windows + * Terminal, xterm.js-based emulators) without spawning a subprocess. + * Returns true if the sequence was written to a TTY; false otherwise. + * A return of true does not guarantee the terminal accepted the write — + * some terminals disable OSC 52 by default. + */ +function copyToClipboardViaOsc52(text: string): boolean { + const base64 = Buffer.from(text, 'utf8').toString('base64'); + const seq = wrapForMultiplexer(`\x1b]52;c;${base64}\x07`); + const stream = process.stderr.isTTY + ? process.stderr + : process.stdout.isTTY + ? process.stdout + : null; + if (!stream) return false; + try { + stream.write(seq); + return true; + } catch { + return false; + } +} export const AuthenticateStep: React.FC = ({ server, @@ -31,6 +72,10 @@ export const AuthenticateStep: React.FC = ({ const [authState, setAuthState] = useState('idle'); const [messages, setMessages] = useState([]); const [errorMessage, setErrorMessage] = useState(null); + const [authUrl, setAuthUrl] = useState(null); + const [copyState, setCopyState] = useState< + { status: 'idle' } | { status: 'copied' | 'unsupported'; nonce: number } + >({ status: 'idle' }); const isRunning = useRef(false); const runAuthentication = useCallback(async () => { @@ -41,15 +86,6 @@ export const AuthenticateStep: React.FC = ({ setMessages([]); setErrorMessage(null); - // 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); - try { setMessages([ t("Starting OAuth authentication for MCP server '{{name}}'...", { @@ -117,10 +153,30 @@ export const AuthenticateStep: React.FC = ({ setAuthState('error'); } finally { isRunning.current = false; - appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); } }, [server, config]); + // Subscribe to OAuth events for the lifetime of this component. Keeping + // the subscription tied to mount/unmount (rather than to runAuthentication's + // async flow) ensures listeners are released immediately on unmount even if + // the authentication promise is still pending. + useEffect(() => { + const displayListener = (message: OAuthDisplayPayload) => { + const text = + typeof message === 'string' ? message : t(message.key, message.params); + setMessages((prev) => [...prev, text]); + }; + const authUrlListener = (url: string) => { + setAuthUrl(url); + }; + appEvents.on(AppEvent.OauthDisplayMessage, displayListener); + appEvents.on(AppEvent.OauthAuthUrl, authUrlListener); + return () => { + appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); + appEvents.removeListener(AppEvent.OauthAuthUrl, authUrlListener); + }; + }, []); + useEffect(() => { runAuthentication(); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -139,11 +195,36 @@ export const AuthenticateStep: React.FC = ({ (key) => { if (key.name === 'escape') { onBack(); + return; + } + if ( + key.name === 'c' && + !key.ctrl && + !key.meta && + !key.paste && + authUrl && + authState === 'authenticating' + ) { + const ok = copyToClipboardViaOsc52(authUrl); + setCopyState({ + status: ok ? 'copied' : 'unsupported', + nonce: Date.now(), + }); } }, { isActive: true }, ); + useEffect(() => { + if (copyState.status === 'idle') return; + const timer = setTimeout( + () => setCopyState({ status: 'idle' }), + COPY_FEEDBACK_MS, + ); + return () => clearTimeout(timer); + // Depend on the nonce so repeated presses reset the timer. + }, [copyState]); + if (!server) { return ( @@ -180,12 +261,32 @@ export const AuthenticateStep: React.FC = ({ )} {/* Action hints */} - + {authState === 'authenticating' && ( {t('Authenticating... Please complete the login in your browser.')} )} + {authState === 'authenticating' && authUrl && ( + + {copyState.status === 'copied' + ? t( + 'Copy request sent to your terminal. If paste is empty, copy the URL above manually.', + ) + : copyState.status === 'unsupported' + ? t('Cannot write to terminal — copy the URL above manually.') + : t('Press c to copy the authorization URL to your clipboard.')} + + )} {authState === 'success' && ( {t('Authentication successful.')} diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts index c29f740bd3d..976f4a751dc 100644 --- a/packages/cli/src/utils/events.ts +++ b/packages/cli/src/utils/events.ts @@ -10,6 +10,7 @@ export enum AppEvent { OpenDebugConsole = 'open-debug-console', LogError = 'log-error', OauthDisplayMessage = 'oauth-display-message', + OauthAuthUrl = 'oauth-auth-url', } export const appEvents = new EventEmitter(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 248dfedffb2..72f9cffc56e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -158,7 +158,11 @@ export * from './lsp/types.js'; // MCP (Model Context Protocol) // ============================================================================ -export { MCPOAuthProvider } from './mcp/oauth-provider.js'; +export { + MCPOAuthProvider, + OAUTH_AUTH_URL_EVENT, + OAUTH_DISPLAY_MESSAGE_EVENT, +} from './mcp/oauth-provider.js'; export type { MCPOAuthConfig, OAuthDisplayMessage, diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index a3e998bb72b..6d01f65effc 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -21,6 +21,7 @@ import { } from './constants.js'; export const OAUTH_DISPLAY_MESSAGE_EVENT = 'oauth-display-message' as const; +export const OAUTH_AUTH_URL_EVENT = 'oauth-auth-url' as const; /** * Structured display message for i18n support. @@ -818,6 +819,9 @@ export class MCPOAuthProvider { displayMessage({ key: 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.', }); + if (events) { + events.emit(OAUTH_AUTH_URL_EVENT, authUrl.toString()); + } // Start callback server const callbackPromise = this.startCallbackServer(pkceParams.state);