claude-code/components/mcp/MCPAgentServerMenu.tsx

183 lines
26 KiB
TypeScript
Raw Normal View History

import figures from 'figures';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { CommandResultDisplay } from '../../commands.js';
import { Box, color, Link, Text, useTheme } from '../../ink.js';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
import { AuthenticationCancelledError, performMCPOAuthFlow } from '../../services/mcp/auth.js';
import { capitalize } from '../../utils/stringUtils.js';
import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js';
import { Select } from '../CustomSelect/index.js';
import { Byline } from '../design-system/Byline.js';
import { Dialog } from '../design-system/Dialog.js';
import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js';
import { Spinner } from '../Spinner.js';
import type { AgentMcpServerInfo } from './types.js';
type Props = {
agentServer: AgentMcpServerInfo;
onCancel: () => void;
onComplete?: (result?: string, options?: {
display?: CommandResultDisplay;
}) => void;
};
/**
* Menu for agent-specific MCP servers.
* These servers are defined in agent frontmatter and only connect when the agent runs.
* For HTTP/SSE servers, this allows pre-authentication before using the agent.
*/
export function MCPAgentServerMenu({
agentServer,
onCancel,
onComplete
}: Props): React.ReactNode {
const [theme] = useTheme();
const [isAuthenticating, setIsAuthenticating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
const authAbortControllerRef = useRef<AbortController | null>(null);
// Abort OAuth flow on unmount so the callback server is closed even if a
// parent component's Esc handler navigates away before ours fires.
useEffect(() => () => authAbortControllerRef.current?.abort(), []);
// Handle ESC to cancel authentication flow
const handleEscCancel = useCallback(() => {
if (isAuthenticating) {
authAbortControllerRef.current?.abort();
authAbortControllerRef.current = null;
setIsAuthenticating(false);
setAuthorizationUrl(null);
}
}, [isAuthenticating]);
useKeybinding('confirm:no', handleEscCancel, {
context: 'Confirmation',
isActive: isAuthenticating
});
const handleAuthenticate = useCallback(async () => {
if (!agentServer.needsAuth || !agentServer.url) {
return;
}
setIsAuthenticating(true);
setError(null);
const controller = new AbortController();
authAbortControllerRef.current = controller;
try {
// Create a temporary config for OAuth
const tempConfig = {
type: agentServer.transport as 'http' | 'sse',
url: agentServer.url
};
await performMCPOAuthFlow(agentServer.name, tempConfig, setAuthorizationUrl, controller.signal);
onComplete?.(`Authentication successful for ${agentServer.name}. The server will connect when the agent runs.`);
} 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;
}
}, [agentServer, onComplete]);
const capitalizedServerName = capitalize(String(agentServer.name));
if (isAuthenticating) {
return <Box flexDirection="column" gap={1} padding={1}>
<Text color="claude">Authenticating with {agentServer.name}</Text>
<Box>
<Spinner />
<Text> A browser window will open for authentication</Text>
</Box>
{authorizationUrl && <Box flexDirection="column">
<Text dimColor>
If your browser doesn&apos;t open automatically, copy this URL
manually:
</Text>
<Link url={authorizationUrl} />
</Box>}
<Box marginLeft={3}>
<Text dimColor>
Return here after authenticating in your browser.{' '}
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" />
</Text>
</Box>
</Box>;
}
const menuOptions = [];
// Only show authenticate option for HTTP/SSE servers
if (agentServer.needsAuth) {
menuOptions.push({
label: agentServer.isAuthenticated ? 'Re-authenticate' : 'Authenticate',
value: 'auth'
});
}
menuOptions.push({
label: 'Back',
value: 'back'
});
return <Dialog title={`${capitalizedServerName} MCP Server`} subtitle="agent-only" onCancel={onCancel} inputGuide={exitState => exitState.pending ? <Text>Press {exitState.keyName} again to exit</Text> : <Byline>
<KeyboardShortcutHint shortcut="↑↓" action="navigate" />
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
<ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="go back" />
</Byline>}>
<Box flexDirection="column" gap={0}>
<Box>
<Text bold>Type: </Text>
<Text dimColor>{agentServer.transport}</Text>
</Box>
{agentServer.url && <Box>
<Text bold>URL: </Text>
<Text dimColor>{agentServer.url}</Text>
</Box>}
{agentServer.command && <Box>
<Text bold>Command: </Text>
<Text dimColor>{agentServer.command}</Text>
</Box>}
<Box>
<Text bold>Used by: </Text>
<Text dimColor>{agentServer.sourceAgents.join(', ')}</Text>
</Box>
<Box marginTop={1}>
<Text bold>Status: </Text>
<Text>
{color('inactive', theme)(figures.radioOff)} not connected
(agent-only)
</Text>
</Box>
{agentServer.needsAuth && <Box>
<Text bold>Auth: </Text>
{agentServer.isAuthenticated ? <Text>{color('success', theme)(figures.tick)} authenticated</Text> : <Text>
{color('warning', theme)(figures.triangleUpOutline)} may need
authentication
</Text>}
</Box>}
</Box>
<Box>
<Text dimColor>This server connects only when running the agent.</Text>
</Box>
{error && <Box>
<Text color="error">Error: {error}</Text>
</Box>}
<Box>
<Select options={menuOptions} onChange={async value => {
switch (value) {
case 'auth':
await handleAuthenticate();
break;
case 'back':
onCancel();
break;
}
}} onCancel={onCancel} />
</Box>
</Dialog>;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmaWd1cmVzIiwiUmVhY3QiLCJ1c2VDYWxsYmFjayIsInVzZUVmZmVjdCIsInVzZVJlZiIsInVzZVN0YXRlIiwiQ29tbWFuZFJlc3VsdERpc3BsYXkiLCJCb3giLCJjb2xvciIsIkxpbmsiLCJUZXh0IiwidXNlVGhlbWUiLCJ1c2VLZXliaW5kaW5nIiwiQXV0aGVudGljYXRpb25DYW5jZWxsZWRFcnJvciIsInBlcmZvcm1NQ1BPQXV0aEZsb3ciLCJjYXBpdGFsaXplIiwiQ29uZmlndXJhYmxlU2hvcnRjdXRIaW50IiwiU2VsZWN0IiwiQnlsaW5lIiwiRGlhbG9nIiwiS2V5Ym9hcmRTaG9ydGN1dEhpbnQiLCJTcGlubmVyIiwiQWdlbnRNY3BTZXJ2ZXJJbmZvIiwiUHJvcHMiLCJhZ2VudFNlcnZlciIsIm9uQ2FuY2VsIiwib25Db21wbGV0ZSIsInJlc3VsdCIsIm9wdGlvbnMiLCJkaXNwbGF5IiwiTUNQQWdlbnRTZXJ2ZXJNZW51IiwiUmVhY3ROb2RlIiwidGhlbWUiLCJpc0F1dGhlbnRpY2F0aW5nIiwic2V0SXNBdXRoZW50aWNhdGluZyIsImVycm9yIiwic2V0RXJyb3IiLCJhdXRob3JpemF0aW9uVXJsIiwic2V0QXV0aG9yaXphdGlvblVybCIsImF1dGhBYm9ydENvbnRyb2xsZXJSZWYiLCJBYm9ydENvbnRyb2xsZXIiLCJjdXJyZW50IiwiYWJvcnQiLCJoYW5kbGVFc2NDYW5jZWwiLCJjb250ZXh0IiwiaXNBY3RpdmUiLCJoYW5kbGVBdXRoZW50aWNhdGUiLCJuZWVkc0F1dGgiLCJ1cmwiLCJjb250cm9sbGVyIiwidGVtcENvbmZpZyIsInR5cGUiLCJ0cmFuc3BvcnQiLCJuYW1lIiwic2lnbmFsIiwiZXJyIiwiRXJyb3IiLCJtZXNzYWdlIiwiY2FwaXRhbGl6ZWRTZXJ2ZXJOYW1lIiwiU3RyaW5nIiwibWVudU9wdGlvbnMiLCJwdXNoIiwibGFiZWwiLCJpc0F1dGhlbnRpY2F0ZWQiLCJ2YWx1ZSIsImV4aXRTdGF0ZSIsInBlbmRpbmciLCJrZXlOYW1lIiwiY29tbWFuZCIsInNvdXJjZUFnZW50cyIsImpvaW4iLCJyYWRpb09mZiIsInRpY2siLCJ0cmlhbmdsZVVwT3V0bGluZSJdLCJzb3VyY2VzIjpbIk1DUEFnZW50U2VydmVyTWVudS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IGZpZ3VyZXMgZnJvbSAnZmlndXJlcydcbmltcG9ydCBSZWFjdCwgeyB1c2VDYWxsYmFjaywgdXNlRWZmZWN0LCB1c2VSZWYsIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IENvbW1hbmRSZXN1bHREaXNwbGF5IH0gZnJvbSAnLi4vLi4vY29tbWFuZHMuanMnXG5pbXBvcnQgeyBCb3gsIGNvbG9yLCBMaW5rLCBUZXh0LCB1c2VUaGVtZSB9IGZyb20gJy4uLy4uL2luay5qcydcbmltcG9ydCB7IHVzZUtleWJpbmRpbmcgfSBmcm9tICcuLi8uLi9rZXliaW5kaW5ncy91c2VLZXliaW5kaW5nLmpzJ1xuaW1wb3J0IHtcbiAgQXV0aGVudGljYXRpb25DYW5jZWxsZWRFcnJvcixcbiAgcGVyZm9ybU1DUE9BdXRoRmxvdyxcbn0gZnJvbSAnLi4vLi4vc2VydmljZXMvbWNwL2F1dGguanMnXG5pbXBvcnQgeyBjYXBpdGFsaXplIH0gZnJvbSAnLi4vLi4vdXRpbHMvc3RyaW5nVXRpbHMuanMnXG5pbXBvcnQgeyBDb25maWd1cmFibGVTaG9ydGN1dEhpbnQgfSBmcm9tICcuLi9Db25maWd1cmFibGVTaG9ydGN1dEhpbnQuanMnXG5pbXBvcnQgeyBTZWxlY3QgfSBmcm9tICcuLi9DdXN0b21TZWxlY3QvaW5kZXguanMnXG5pbXBvcnQgeyBCeWxpbmUgfSBmcm9tICcuLi9kZXNpZ24tc3lzdGVtL0J5bGluZS5qcydcbmltcG9ydCB7IERpYWxvZyB9IGZyb20gJy4uL2Rlc2lnbi1zeXN0ZW0vRGlhbG9nLmpzJ1xuaW1wb3J0IHsgS2V5Ym9hcmRTaG9ydGN1dEhpbnQgfSBmcm9tICcuLi9kZXNpZ24tc3lzdGVtL0tleWJvYXJkU2hvcnRjdXRIaW50LmpzJ1xuaW1wb3J0IHsgU3Bpbm5lciB9IGZyb20gJy4uL1NwaW5uZXIuanMnXG5pbXBvcnQgdHlwZSB7IEFnZW50TWNwU2VydmVySW5mbyB9IGZyb20gJy4vdHlwZXMuanMnXG5cbnR5cGUgUHJvcHMgPSB7XG4gIGFnZW50U2VydmVyOiBBZ2VudE1jcFNlcnZlckluZm9cbiAgb25DYW5jZWw6ICgpID0+IHZvaWRcbiAgb25Db21wbGV0ZT86IChcbiAgICByZXN1bHQ/OiBzdHJpbmcsXG4gICAgb3B0aW9ucz86IHsgZGlzcGxheT86IENvbW1hbmRSZXN1bHREaXNwbGF5IH0sXG4gICkgPT4gdm9pZFxufVxuXG4vKipcbiAqIE1lbnUgZm9yIGFnZW50LXNwZWNpZmljIE1DUCBzZXJ2ZXJzLlxuICogVGhlc2Ugc2VydmVycyBhcmUgZGVmaW5lZCBpbiBhZ2VudCBmcm9udG1hdHRlciBhbmQgb25seSBjb25uZWN0IHdoZW4gdGhlIGFnZW50IHJ1bnMuXG4gKiBGb3IgSFRUUC9TU0Ugc2VydmVycywgdGhpcyBhbGxvd3MgcHJlLWF1dGhlbnRpY2F0aW9uIGJlZm9yZSB1c2luZyB0aGUgYWdlbnQuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBNQ1BBZ2VudFNlcnZlck1lbnUoe1xuICBhZ2VudFNlcnZlcixcbiAgb25DYW5jZWwsXG4gIG9uQ29tcGxldGUsXG59OiBQcm9wcyk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGNvbnN0IFt0aGVtZV0gPSB1c2VUaGVtZSgpXG4gIGNvbnN0IFtpc0F1dGhlbnRpY2F0aW5nLCBzZXRJc0F1dGhlbnRpY2F0aW5nXSA9IHVzZVN0YXRlKGZhbHNlKVxuICBjb25zdCBbZXJyb3IsIHNldEVycm9yXSA9IHVzZVN0YXRlPHN0cmluZyB8IG51bGw+KG51bGwpXG4gIGNvbnN0IFthdXRob3JpemF0aW9uVXJsLCBzZXRBdXRob3JpemF0aW9uVXJsXSA9IHVzZVN0YXRlPHN0cmluZyB8IG51bGw+KG51bGwpXG4gIGNvbnN0IGF1dGhBYm9ydENvbnRyb2xsZXJSZWYgPSB1c2VSZWY8QWJvcnRDb250cm9sbGVyIHwgbnVsbD4obnVsbClcblxuICAvLyBBYm9ydCBPQXV0aCBmbG93IG9uIHVubW91bnQgc28gdGhlIGNhbGxiYWNrIHNlcnZlciBpcyBjbG9zZWQgZXZlbiBpZiBhXG4gIC8vIHBhcmVudCBjb21wb25lbnQncyBFc2MgaGFuZGxlciBuYXZpZ2F0ZXMgYXdheSBiZWZvcmUgb3VycyBmaXJlcy5cbiAgdXNlRWZmZWN0KCgpID0+ICgpID0+IGF1dGhBYm9ydENvbnRyb2xsZXJSZWYuY3VycmVudD8uYWJvcnQoKSwgW10pXG5cbiAgLy8gSGFuZGxlIEVTQyB0byBjYW5jZWwgYXV0aGVudGljYXRpb24gZmxvd1xuICBjb25zdCBoYW5kbGVFc2NDYW5jZWwgPSB1c2VDYWx