From 14c41be6221710071e889f30f9561113468b3001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= <1048927295@qq.com> Date: Fri, 17 Apr 2026 19:54:30 +0800 Subject: [PATCH] feat(mcp): add OSC 52 copy hotkey for OAuth authorization URL (#3337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When MCP OAuth authentication falls back to the "copy this URL into your browser" path (e.g. remote/web terminal where the browser can't auto-open), long URLs wrap across lines inside the bordered dialog and the trailing │ border characters get selected alongside the URL, forcing the user to manually strip them out before pasting. Surface the URL on a dedicated event and let the user press 'c' to push it to the local clipboard via an OSC 52 escape sequence. Works through SSH and modern web terminals (iTerm2, Windows Terminal, xterm.js-based emulators, tmux with set-clipboard, etc.) without a subprocess, and falls back to a visible "copy the URL above manually" hint when the terminal is not a TTY or OSC 52 is blocked. Key points: - OAuth provider emits OAUTH_AUTH_URL_EVENT carrying the full URL. - AuthenticateStep listens, tracks it in state, and binds 'c' while authenticating (modifier/paste keys are filtered out). - copyToClipboardViaOsc52 writes to stderr when it's a TTY, falls back to stdout, and wraps the sequence for tmux/GNU screen via DCS passthrough so multiplexed sessions still work. - Honest feedback: distinct "copy request sent" / "cannot write to terminal" states with a short auto-revert so repeated presses reset the timer. Fixes #3337 --- packages/cli/src/i18n/locales/en.js | 6 + packages/cli/src/i18n/locales/zh.js | 6 + .../components/mcp/steps/AuthenticateStep.tsx | 123 ++++++++++++++++-- packages/cli/src/utils/events.ts | 1 + packages/core/src/index.ts | 6 +- packages/core/src/mcp/oauth-provider.ts | 4 + 6 files changed, 134 insertions(+), 12 deletions(-) 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);