Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
123 changes: 112 additions & 11 deletions packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthenticateStepProps> = ({
server,
Expand All @@ -31,6 +72,10 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
const [authState, setAuthState] = useState<AuthState>('idle');
const [messages, setMessages] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [authUrl, setAuthUrl] = useState<string | null>(null);
const [copyState, setCopyState] = useState<
{ status: 'idle' } | { status: 'copied' | 'unsupported'; nonce: number }
>({ status: 'idle' });
const isRunning = useRef(false);

const runAuthentication = useCallback(async () => {
Expand All @@ -41,15 +86,6 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
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}}'...", {
Expand Down Expand Up @@ -117,10 +153,30 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
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
Expand All @@ -139,11 +195,36 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
(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 (
<Box>
Expand Down Expand Up @@ -180,12 +261,32 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
)}

{/* Action hints */}
<Box>
<Box flexDirection="column">
{authState === 'authenticating' && (
<Text color={theme.text.secondary}>
{t('Authenticating... Please complete the login in your browser.')}
</Text>
)}
{authState === 'authenticating' && authUrl && (
<Text
bold={copyState.status === 'idle'}
color={
copyState.status === 'copied'
? theme.status.success
: copyState.status === 'unsupported'
? theme.status.warning
: theme.text.accent
}
>
{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.')}
</Text>
)}
{authState === 'success' && (
<Text color={theme.status.success}>
{t('Authentication successful.')}
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
6 changes: 5 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/mcp/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Loading