import figures from 'figures' import React, { useEffect, useRef, useState } from 'react' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import type { CommandResultDisplay } from '../../commands.js' import { getOauthConfig } from '../../constants/oauth.js' import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js' import { setClipboard } from '@anthropic/ink' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow menu navigation import { Box, color, Link, Text, useInput, useTheme } from '@anthropic/ink' import { useKeybinding } from '../../keybindings/useKeybinding.js' import { AuthenticationCancelledError, performMCPOAuthFlow, revokeServerTokens, } from '../../services/mcp/auth.js' import { clearServerCache } from '../../services/mcp/client.js' import { useMcpReconnect, useMcpToggleEnabled, } from '../../services/mcp/MCPConnectionManager.js' import { describeMcpConfigFilePath, excludeCommandsByServer, excludeResourcesByServer, excludeToolsByServer, filterMcpPromptsByServer, } from '../../services/mcp/utils.js' import { useAppState, useSetAppState } from '../../state/AppState.js' import { getOauthAccountInfo } from '../../utils/auth.js' import { openBrowser } from '../../utils/browser.js' import { errorMessage } from '../../utils/errors.js' import { logMCPDebug } from '../../utils/log.js' import { capitalize } from '../../utils/stringUtils.js' import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' import { Select } from '../CustomSelect/index.js' import { Byline, KeyboardShortcutHint } from '@anthropic/ink' import { Spinner } from '../Spinner.js' import TextInput from '../TextInput.js' import { CapabilitiesSection } from './CapabilitiesSection.js' import type { ClaudeAIServerInfo, HTTPServerInfo, SSEServerInfo, } from './types.js' import { handleReconnectError, handleReconnectResult, } from './utils/reconnectHelpers.js' type Props = { server: SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo serverToolsCount: number onViewTools: () => void onCancel: () => void onComplete?: ( result?: string, options?: { display?: CommandResultDisplay }, ) => void borderless?: boolean } export function MCPRemoteServerMenu({ server, serverToolsCount, onViewTools, onCancel, onComplete, borderless = false, }: Props): React.ReactNode { const [theme] = useTheme() const exitState = useExitOnCtrlCDWithKeybindings() const { columns: terminalColumns } = useTerminalSize() const [isAuthenticating, setIsAuthenticating] = React.useState(false) const [error, setError] = React.useState(null) const mcp = useAppState(s => s.mcp) const setAppState = useSetAppState() const [authorizationUrl, setAuthorizationUrl] = React.useState( null, ) const [isReconnecting, setIsReconnecting] = useState(false) const authAbortControllerRef = useRef(null) const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = useState(false) const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState(null) const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false) const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState< string | null >(null) const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] = useState(false) const [urlCopied, setUrlCopied] = useState(false) const copyTimeoutRef = useRef | undefined>( undefined, ) const unmountedRef = useRef(false) const [callbackUrlInput, setCallbackUrlInput] = useState('') const [callbackUrlCursorOffset, setCallbackUrlCursorOffset] = useState(0) const [manualCallbackSubmit, setManualCallbackSubmit] = useState< ((url: string) => void) | null >(null) // If the component unmounts mid-auth (e.g. a parent component's Esc handler // navigates away before ours fires), abort the OAuth flow so the callback // server is closed. Without this, the server stays bound and the process // can outlive the terminal. Also clear the copy-feedback timer and mark // unmounted so the async setClipboard callback doesn't setUrlCopied / // schedule a new timer after unmount. useEffect( () => () => { unmountedRef.current = true authAbortControllerRef.current?.abort() if (copyTimeoutRef.current !== undefined) { clearTimeout(copyTimeoutRef.current) } }, [], ) // A server is effectively authenticated if: // 1. It has OAuth tokens (server.isAuthenticated), OR // 2. It's connected and has tools (meaning it's working via some auth mechanism) const isEffectivelyAuthenticated = server.isAuthenticated || (server.client.type === 'connected' && serverToolsCount > 0) const reconnectMcpServer = useMcpReconnect() const handleClaudeAIAuthComplete = React.useCallback(async () => { setIsClaudeAIAuthenticating(false) setClaudeAIAuthUrl(null) setIsReconnecting(true) try { const result = await reconnectMcpServer(server.name) const success = result.client.type === 'connected' logEvent('tengu_claudeai_mcp_auth_completed', { success }) if (success) { onComplete?.(`Authentication successful. Connected to ${server.name}.`) } else if (result.client.type === 'needs-auth') { onComplete?.( 'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.', ) } else { onComplete?.( 'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.', ) } } catch (err) { logEvent('tengu_claudeai_mcp_auth_completed', { success: false }) onComplete?.(handleReconnectError(err, server.name)) } finally { setIsReconnecting(false) } }, [reconnectMcpServer, server.name, onComplete]) const handleClaudeAIClearAuthComplete = React.useCallback(async () => { await clearServerCache(server.name, { ...server.config, scope: server.scope, }) setAppState(prev => { const newClients = prev.mcp.clients.map(c => c.name === server.name ? { ...c, type: 'needs-auth' as const } : c, ) const newTools = excludeToolsByServer(prev.mcp.tools, server.name) const newCommands = excludeCommandsByServer( prev.mcp.commands, server.name, ) const newResources = excludeResourcesByServer( prev.mcp.resources, server.name, ) return { ...prev, mcp: { ...prev.mcp, clients: newClients, tools: newTools, commands: newCommands, resources: newResources, }, } }) logEvent('tengu_claudeai_mcp_clear_auth_completed', {}) onComplete?.(`Disconnected from ${server.name}.`) setIsClaudeAIClearingAuth(false) setClaudeAIClearAuthUrl(null) setClaudeAIClearAuthBrowserOpened(false) }, [server.name, server.config, server.scope, setAppState, onComplete]) // Escape to cancel authentication flow useKeybinding( 'confirm:no', () => { authAbortControllerRef.current?.abort() authAbortControllerRef.current = null setIsAuthenticating(false) setAuthorizationUrl(null) }, { context: 'Confirmation', isActive: isAuthenticating, }, ) // Escape to cancel Claude AI authentication useKeybinding( 'confirm:no', () => { setIsClaudeAIAuthenticating(false) setClaudeAIAuthUrl(null) }, { context: 'Confirmation', isActive: isClaudeAIAuthenticating, }, ) // Escape to cancel Claude AI clear auth useKeybinding( 'confirm:no', () => { setIsClaudeAIClearingAuth(false) setClaudeAIClearAuthUrl(null) setClaudeAIClearAuthBrowserOpened(false) }, { context: 'Confirmation', isActive: isClaudeAIClearingAuth, }, ) // Return key handling for authentication flows and 'c' to copy URL useInput((input, key) => { if (key.return && isClaudeAIAuthenticating) { void handleClaudeAIAuthComplete() } if (key.return && isClaudeAIClearingAuth) { if (claudeAIClearAuthBrowserOpened) { void handleClaudeAIClearAuthComplete() } else { // First Enter: open the browser const connectorsUrl = `${getOauthConfig().CLAUDE_AI_ORIGIN}/settings/connectors` setClaudeAIClearAuthUrl(connectorsUrl) setClaudeAIClearAuthBrowserOpened(true) void openBrowser(connectorsUrl) } } if (input === 'c' && !urlCopied) { const urlToCopy = authorizationUrl || claudeAIAuthUrl || claudeAIClearAuthUrl if (urlToCopy) { void setClipboard(urlToCopy).then(raw => { if (unmountedRef.current) return if (raw) process.stdout.write(raw) setUrlCopied(true) if (copyTimeoutRef.current !== undefined) { clearTimeout(copyTimeoutRef.current) } copyTimeoutRef.current = setTimeout(setUrlCopied, 2000, false) }) } } }) const capitalizedServerName = capitalize(String(server.name)) // Count MCP prompts for this server (skills are shown in /skills, not here) const serverCommandsCount = filterMcpPromptsByServer( mcp.commands, server.name, ).length const toggleMcpServer = useMcpToggleEnabled() const handleClaudeAIAuth = React.useCallback(async () => { const claudeAiBaseUrl = getOauthConfig().CLAUDE_AI_ORIGIN const accountInfo = getOauthAccountInfo() const orgUuid = accountInfo?.organizationUuid let authUrl: string if ( orgUuid && server.config.type === 'claudeai-proxy' && server.config.id ) { // Use the direct auth URL with org and server IDs // Replace 'mcprs' prefix with 'mcpsrv' if present const serverId = server.config.id.startsWith('mcprs') ? 'mcpsrv' + server.config.id.slice(5) : server.config.id const productSurface = encodeURIComponent( process.env.CLAUDE_CODE_ENTRYPOINT || 'cli', ) authUrl = `${claudeAiBaseUrl}/api/organizations/${orgUuid}/mcp/start-auth/${serverId}?product_surface=${productSurface}` } else { // Fall back to settings/connectors if we don't have the required IDs authUrl = `${claudeAiBaseUrl}/settings/connectors` } setClaudeAIAuthUrl(authUrl) setIsClaudeAIAuthenticating(true) logEvent('tengu_claudeai_mcp_auth_started', {}) await openBrowser(authUrl) }, [server.config]) const handleClaudeAIClearAuth = React.useCallback(() => { setIsClaudeAIClearingAuth(true) logEvent('tengu_claudeai_mcp_clear_auth_started', {}) }, []) const handleToggleEnabled = React.useCallback(async () => { const wasEnabled = server.client.type !== 'disabled' try { await toggleMcpServer(server.name) if (server.config.type === 'claudeai-proxy') { logEvent('tengu_claudeai_mcp_toggle', { new_state: (wasEnabled ? 'disabled' : 'enabled') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) } // Return to the server list so user can continue managing other servers onCancel() } catch (err) { const action = wasEnabled ? 'disable' : 'enable' onComplete?.( `Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`, ) } }, [ server.client.type, server.config.type, server.name, toggleMcpServer, onCancel, onComplete, ]) const handleAuthenticate = React.useCallback(async () => { if (server.config.type === 'claudeai-proxy') return setIsAuthenticating(true) setError(null) const controller = new AbortController() authAbortControllerRef.current = controller try { // Revoke existing tokens if re-authenticating, but preserve step-up // auth state so the next OAuth flow can reuse cached scope/discovery. if (server.isAuthenticated && server.config) { await revokeServerTokens(server.name, server.config, { preserveStepUpState: true, }) } if (server.config) { await performMCPOAuthFlow( server.name, server.config, setAuthorizationUrl, controller.signal, { onWaitingForCallback: submit => { setManualCallbackSubmit(() => submit) }, }, ) logEvent('tengu_mcp_auth_config_authenticate', { wasAuthenticated: server.isAuthenticated, }) const result = await reconnectMcpServer(server.name) if (result.client.type === 'connected') { const message = isEffectivelyAuthenticated ? `Authentication successful. Reconnected to ${server.name}.` : `Authentication successful. Connected to ${server.name}.` onComplete?.(message) } else if (result.client.type === 'needs-auth') { onComplete?.( 'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.', ) } else { // result.client.type === 'failed' logMCPDebug(server.name, `Reconnection failed after authentication`) onComplete?.( 'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.', ) } } } catch (err) { // Don't show error if it was a cancellation if ( err instanceof Error && !(err instanceof AuthenticationCancelledError) ) { setError(err.message) } } finally { setIsAuthenticating(false) authAbortControllerRef.current = null setManualCallbackSubmit(null) setCallbackUrlInput('') } }, [ server.isAuthenticated, server.config, server.name, onComplete, reconnectMcpServer, isEffectivelyAuthenticated, ]) const handleClearAuth = async () => { if (server.config.type === 'claudeai-proxy') return if (server.config) { // First revoke the authentication tokens and clear all auth state await revokeServerTokens(server.name, server.config) logEvent('tengu_mcp_auth_config_clear', {}) // Disconnect the client and clear the cache await clearServerCache(server.name, { ...server.config, scope: server.scope, }) // Update app state to remove the disconnected server's tools, commands, and resources setAppState(prev => { const newClients = prev.mcp.clients.map(c => // 'failed' is a misnomer here, but we don't really differentiate between "not connected" and "failed" at the moment c.name === server.name ? { ...c, type: 'failed' as const } : c, ) const newTools = excludeToolsByServer(prev.mcp.tools, server.name) const newCommands = excludeCommandsByServer( prev.mcp.commands, server.name, ) const newResources = excludeResourcesByServer( prev.mcp.resources, server.name, ) return { ...prev, mcp: { ...prev.mcp, clients: newClients, tools: newTools, commands: newCommands, resources: newResources, }, } }) onComplete?.(`Authentication cleared for ${server.name}.`) } } if (isAuthenticating) { // XAA: silent exchange (cached id_token → no browser), so don't claim // one will open. If IdP login IS needed, authorizationUrl populates and // the URL fallback block below still renders. const authCopy = server.config.type !== 'claudeai-proxy' && server.config.oauth?.xaa ? ' Authenticating via your identity provider' : ' A browser window will open for authentication' return ( Authenticating with {server.name}… {authCopy} {authorizationUrl && ( If your browser doesn't open automatically, copy this URL manually{' '} {urlCopied ? ( (Copied!) ) : ( )} )} {isAuthenticating && authorizationUrl && manualCallbackSubmit && ( If the redirect page shows a connection error, paste the URL from your browser's address bar: URL {'>'} { manualCallbackSubmit(value.trim()) setCallbackUrlInput('') }} cursorOffset={callbackUrlCursorOffset} onChangeCursorOffset={setCallbackUrlCursorOffset} columns={terminalColumns - 8} /> )} Return here after authenticating in your browser. Press Esc to go back. ) } if (isClaudeAIAuthenticating) { return ( Authenticating with {server.name}… A browser window will open for authentication {claudeAIAuthUrl && ( If your browser doesn't open automatically, copy this URL manually{' '} {urlCopied ? ( (Copied!) ) : ( )} )} Press Enter after authenticating in your browser. ) } if (isClaudeAIClearingAuth) { return ( Clear authentication for {server.name} {claudeAIClearAuthBrowserOpened ? ( <> Find the MCP server in the browser and click "Disconnect". {claudeAIClearAuthUrl && ( If your browser didn't open automatically, copy this URL manually{' '} {urlCopied ? ( (Copied!) ) : ( )} )} Press Enter when done. ) : ( <> This will open claude.ai in the browser. Find the MCP server in the list and click "Disconnect". Press Enter to open the browser. )} ) } if (isReconnecting) { return ( Connecting to {server.name} Establishing connection to MCP server This may take a few moments. ) } const menuOptions = [] // If server is disabled, show Enable first as the primary action if (server.client.type === 'disabled') { menuOptions.push({ label: 'Enable', value: 'toggle-enabled', }) } if (server.client.type === 'connected' && serverToolsCount > 0) { menuOptions.push({ label: 'View tools', value: 'tools', }) } if (server.config.type === 'claudeai-proxy') { if (server.client.type === 'connected') { menuOptions.push({ label: 'Clear authentication', value: 'claudeai-clear-auth', }) } else if (server.client.type !== 'disabled') { menuOptions.push({ label: 'Authenticate', value: 'claudeai-auth', }) } } else { if (isEffectivelyAuthenticated) { menuOptions.push({ label: 'Re-authenticate', value: 'reauth', }) menuOptions.push({ label: 'Clear authentication', value: 'clear-auth', }) } if (!isEffectivelyAuthenticated) { menuOptions.push({ label: 'Authenticate', value: 'auth', }) } } if (server.client.type !== 'disabled') { if (server.client.type !== 'needs-auth') { menuOptions.push({ label: 'Reconnect', value: 'reconnectMcpServer', }) } menuOptions.push({ label: 'Disable', value: 'toggle-enabled', }) } // If there are no other options, add a back option so Select handles escape if (menuOptions.length === 0) { menuOptions.push({ label: 'Back', value: 'back', }) } return ( {capitalizedServerName} MCP Server Status: {server.client.type === 'disabled' ? ( {color('inactive', theme)(figures.radioOff)} disabled ) : server.client.type === 'connected' ? ( {color('success', theme)(figures.tick)} connected ) : server.client.type === 'pending' ? ( <> {figures.radioOff} connecting… ) : server.client.type === 'needs-auth' ? ( {color('warning', theme)(figures.triangleUpOutline)} needs authentication ) : ( {color('error', theme)(figures.cross)} failed )} {server.transport !== 'claudeai-proxy' && ( Auth: {isEffectivelyAuthenticated ? ( {color('success', theme)(figures.tick)} authenticated ) : ( {color('error', theme)(figures.cross)} not authenticated )} )} URL: {server.config.url} Config location: {describeMcpConfigFilePath(server.scope)} {server.client.type === 'connected' && ( )} {server.client.type === 'connected' && serverToolsCount > 0 && ( Tools: {serverToolsCount} tools )} {error && ( Error: {error} )} {menuOptions.length > 0 && (