mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 06:15:51 +00:00
895 lines
28 KiB
TypeScript
895 lines
28 KiB
TypeScript
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<string | null>(null)
|
|
const mcp = useAppState(s => s.mcp)
|
|
const setAppState = useSetAppState()
|
|
const [authorizationUrl, setAuthorizationUrl] = React.useState<string | null>(
|
|
null,
|
|
)
|
|
const [isReconnecting, setIsReconnecting] = useState(false)
|
|
const authAbortControllerRef = useRef<AbortController | null>(null)
|
|
const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] =
|
|
useState(false)
|
|
const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState<string | null>(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<ReturnType<typeof setTimeout> | 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 (
|
|
<Box flexDirection="column" gap={1} padding={1}>
|
|
<Text color="claude">Authenticating with {server.name}…</Text>
|
|
<Box>
|
|
<Spinner />
|
|
<Text>{authCopy}</Text>
|
|
</Box>
|
|
{authorizationUrl && (
|
|
<Box flexDirection="column">
|
|
<Box>
|
|
<Text dimColor>
|
|
If your browser doesn't open automatically, copy this URL
|
|
manually{' '}
|
|
</Text>
|
|
{urlCopied ? (
|
|
<Text color="success">(Copied!)</Text>
|
|
) : (
|
|
<Text dimColor>
|
|
<KeyboardShortcutHint shortcut="c" action="copy" parens />
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Link url={authorizationUrl} />
|
|
</Box>
|
|
)}
|
|
{isAuthenticating && authorizationUrl && manualCallbackSubmit && (
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<Text dimColor>
|
|
If the redirect page shows a connection error, paste the URL from
|
|
your browser's address bar:
|
|
</Text>
|
|
<Box>
|
|
<Text dimColor>URL {'>'} </Text>
|
|
<TextInput
|
|
value={callbackUrlInput}
|
|
onChange={setCallbackUrlInput}
|
|
onSubmit={(value: string) => {
|
|
manualCallbackSubmit(value.trim())
|
|
setCallbackUrlInput('')
|
|
}}
|
|
cursorOffset={callbackUrlCursorOffset}
|
|
onChangeCursorOffset={setCallbackUrlCursorOffset}
|
|
columns={terminalColumns - 8}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
<Box marginLeft={3}>
|
|
<Text dimColor>
|
|
Return here after authenticating in your browser. Press Esc to go
|
|
back.
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (isClaudeAIAuthenticating) {
|
|
return (
|
|
<Box flexDirection="column" gap={1} padding={1}>
|
|
<Text color="claude">Authenticating with {server.name}…</Text>
|
|
<Box>
|
|
<Spinner />
|
|
<Text> A browser window will open for authentication</Text>
|
|
</Box>
|
|
{claudeAIAuthUrl && (
|
|
<Box flexDirection="column">
|
|
<Box>
|
|
<Text dimColor>
|
|
If your browser doesn't open automatically, copy this URL
|
|
manually{' '}
|
|
</Text>
|
|
{urlCopied ? (
|
|
<Text color="success">(Copied!)</Text>
|
|
) : (
|
|
<Text dimColor>
|
|
<KeyboardShortcutHint shortcut="c" action="copy" parens />
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Link url={claudeAIAuthUrl} />
|
|
</Box>
|
|
)}
|
|
<Box marginLeft={3} flexDirection="column">
|
|
<Text color="permission">
|
|
Press <Text bold>Enter</Text> after authenticating in your browser.
|
|
</Text>
|
|
<Text dimColor italic>
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="back"
|
|
/>
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (isClaudeAIClearingAuth) {
|
|
return (
|
|
<Box flexDirection="column" gap={1} padding={1}>
|
|
<Text color="claude">Clear authentication for {server.name}</Text>
|
|
{claudeAIClearAuthBrowserOpened ? (
|
|
<>
|
|
<Text>
|
|
Find the MCP server in the browser and click
|
|
"Disconnect".
|
|
</Text>
|
|
{claudeAIClearAuthUrl && (
|
|
<Box flexDirection="column">
|
|
<Box>
|
|
<Text dimColor>
|
|
If your browser didn't open automatically, copy this
|
|
URL manually{' '}
|
|
</Text>
|
|
{urlCopied ? (
|
|
<Text color="success">(Copied!)</Text>
|
|
) : (
|
|
<Text dimColor>
|
|
<KeyboardShortcutHint shortcut="c" action="copy" parens />
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
<Link url={claudeAIClearAuthUrl} />
|
|
</Box>
|
|
)}
|
|
<Box marginLeft={3} flexDirection="column">
|
|
<Text color="permission">
|
|
Press <Text bold>Enter</Text> when done.
|
|
</Text>
|
|
<Text dimColor italic>
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="back"
|
|
/>
|
|
</Text>
|
|
</Box>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text>
|
|
This will open claude.ai in the browser. Find the MCP server in
|
|
the list and click "Disconnect".
|
|
</Text>
|
|
<Box marginLeft={3} flexDirection="column">
|
|
<Text color="permission">
|
|
Press <Text bold>Enter</Text> to open the browser.
|
|
</Text>
|
|
<Text dimColor italic>
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="back"
|
|
/>
|
|
</Text>
|
|
</Box>
|
|
</>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
if (isReconnecting) {
|
|
return (
|
|
<Box flexDirection="column" gap={1} padding={1}>
|
|
<Text color="text">
|
|
Connecting to <Text bold>{server.name}</Text>…
|
|
</Text>
|
|
<Box>
|
|
<Spinner />
|
|
<Text> Establishing connection to MCP server</Text>
|
|
</Box>
|
|
<Text dimColor>This may take a few moments.</Text>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Box flexDirection="column">
|
|
<Box
|
|
flexDirection="column"
|
|
paddingX={1}
|
|
borderStyle={borderless ? undefined : 'round'}
|
|
>
|
|
<Box marginBottom={1}>
|
|
<Text bold>{capitalizedServerName} MCP Server</Text>
|
|
</Box>
|
|
|
|
<Box flexDirection="column" gap={0}>
|
|
<Box>
|
|
<Text bold>Status: </Text>
|
|
{server.client.type === 'disabled' ? (
|
|
<Text>{color('inactive', theme)(figures.radioOff)} disabled</Text>
|
|
) : server.client.type === 'connected' ? (
|
|
<Text>{color('success', theme)(figures.tick)} connected</Text>
|
|
) : server.client.type === 'pending' ? (
|
|
<>
|
|
<Text dimColor>{figures.radioOff}</Text>
|
|
<Text> connecting…</Text>
|
|
</>
|
|
) : server.client.type === 'needs-auth' ? (
|
|
<Text>
|
|
{color('warning', theme)(figures.triangleUpOutline)} needs
|
|
authentication
|
|
</Text>
|
|
) : (
|
|
<Text>{color('error', theme)(figures.cross)} failed</Text>
|
|
)}
|
|
</Box>
|
|
|
|
{server.transport !== 'claudeai-proxy' && (
|
|
<Box>
|
|
<Text bold>Auth: </Text>
|
|
{isEffectivelyAuthenticated ? (
|
|
<Text>
|
|
{color('success', theme)(figures.tick)} authenticated
|
|
</Text>
|
|
) : (
|
|
<Text>
|
|
{color('error', theme)(figures.cross)} not authenticated
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
<Box>
|
|
<Text bold>URL: </Text>
|
|
<Text dimColor>{server.config.url}</Text>
|
|
</Box>
|
|
|
|
<Box>
|
|
<Text bold>Config location: </Text>
|
|
<Text dimColor>{describeMcpConfigFilePath(server.scope)}</Text>
|
|
</Box>
|
|
|
|
{server.client.type === 'connected' && (
|
|
<CapabilitiesSection
|
|
serverToolsCount={serverToolsCount}
|
|
serverPromptsCount={serverCommandsCount}
|
|
serverResourcesCount={mcp.resources[server.name]?.length || 0}
|
|
/>
|
|
)}
|
|
|
|
{server.client.type === 'connected' && serverToolsCount > 0 && (
|
|
<Box>
|
|
<Text bold>Tools: </Text>
|
|
<Text dimColor>{serverToolsCount} tools</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
{error && (
|
|
<Box marginTop={1}>
|
|
<Text color="error">Error: {error}</Text>
|
|
</Box>
|
|
)}
|
|
|
|
{menuOptions.length > 0 && (
|
|
<Box marginTop={1}>
|
|
<Select
|
|
options={menuOptions}
|
|
onChange={async value => {
|
|
switch (value) {
|
|
case 'tools':
|
|
onViewTools()
|
|
break
|
|
case 'auth':
|
|
case 'reauth':
|
|
await handleAuthenticate()
|
|
break
|
|
case 'clear-auth':
|
|
await handleClearAuth()
|
|
break
|
|
case 'claudeai-auth':
|
|
await handleClaudeAIAuth()
|
|
break
|
|
case 'claudeai-clear-auth':
|
|
handleClaudeAIClearAuth()
|
|
break
|
|
case 'reconnectMcpServer':
|
|
setIsReconnecting(true)
|
|
try {
|
|
const result = await reconnectMcpServer(server.name)
|
|
if (server.config.type === 'claudeai-proxy') {
|
|
logEvent('tengu_claudeai_mcp_reconnect', {
|
|
success: result.client.type === 'connected',
|
|
})
|
|
}
|
|
const { message } = handleReconnectResult(
|
|
result,
|
|
server.name,
|
|
)
|
|
onComplete?.(message)
|
|
} catch (err) {
|
|
if (server.config.type === 'claudeai-proxy') {
|
|
logEvent('tengu_claudeai_mcp_reconnect', {
|
|
success: false,
|
|
})
|
|
}
|
|
onComplete?.(handleReconnectError(err, server.name))
|
|
} finally {
|
|
setIsReconnecting(false)
|
|
}
|
|
break
|
|
case 'toggle-enabled':
|
|
await handleToggleEnabled()
|
|
break
|
|
case 'back':
|
|
onCancel()
|
|
break
|
|
}
|
|
}}
|
|
onCancel={onCancel}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<Box marginTop={1}>
|
|
<Text dimColor italic>
|
|
{exitState.pending ? (
|
|
<>Press {exitState.keyName} again to exit</>
|
|
) : (
|
|
<Byline>
|
|
<KeyboardShortcutHint shortcut="↑↓" action="navigate" />
|
|
<KeyboardShortcutHint shortcut="Enter" action="select" />
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="back"
|
|
/>
|
|
</Byline>
|
|
)}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|