From 7efb6236411a7543e3c4bdb0adf4cc59a25aab99 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 26 Feb 2026 11:16:25 -0500 Subject: [PATCH] feat: add outbound auth wizard step with inline credential creation --- .../screens/mcp/AddGatewayTargetScreen.tsx | 303 +++++++++++++++++- src/cli/tui/screens/mcp/types.ts | 16 +- .../screens/mcp/useAddGatewayTargetWizard.ts | 18 +- 3 files changed, 321 insertions(+), 16 deletions(-) diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 7364bbe9..30ece187 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -1,14 +1,15 @@ import { ToolNameSchema } from '../../../../schema'; -import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import { ConfirmReview, Panel, Screen, SecretInput, StepIndicator, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; +import { useCreateIdentity, useExistingCredentialNames } from '../identity/useCreateIdentity.js'; import type { AddGatewayTargetConfig } from './types'; -import { MCP_TOOL_STEP_LABELS, SKIP_FOR_NOW } from './types'; +import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS, SKIP_FOR_NOW } from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; import { Box, Text } from 'ink'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; interface AddGatewayTargetScreenProps { existingGateways: string[]; @@ -24,6 +25,17 @@ export function AddGatewayTargetScreen({ onExit, }: AddGatewayTargetScreenProps) { const wizard = useAddGatewayTargetWizard(existingGateways); + const { names: existingCredentialNames } = useExistingCredentialNames(); + const { createIdentity } = useCreateIdentity(); + + // Outbound auth sub-step state + const [outboundAuthType, setOutboundAuthTypeLocal] = useState(null); + const [credentialName, setCredentialNameLocal] = useState(null); + const [isCreatingCredential, setIsCreatingCredential] = useState(false); + const [oauthSubStep, setOauthSubStep] = useState<'name' | 'client-id' | 'client-secret' | 'discovery-url'>('name'); + const [oauthFields, setOauthFields] = useState({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + const [apiKeySubStep, setApiKeySubStep] = useState<'name' | 'api-key'>('name'); + const [apiKeyFields, setApiKeyFields] = useState({ name: '', apiKey: '' }); const gatewayItems: SelectableItem[] = useMemo( () => [ @@ -33,7 +45,23 @@ export function AddGatewayTargetScreen({ [existingGateways] ); + const outboundAuthItems: SelectableItem[] = useMemo( + () => OUTBOUND_AUTH_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), + [] + ); + + const credentialItems: SelectableItem[] = useMemo(() => { + const items: SelectableItem[] = [ + { id: 'create-new', title: 'Create new credential', description: 'Create a new credential inline' }, + ]; + existingCredentialNames.forEach(name => { + items.push({ id: name, title: name, description: 'Use existing credential' }); + }); + return items; + }, [existingCredentialNames]); + const isGatewayStep = wizard.step === 'gateway'; + const isOutboundAuthStep = wizard.step === 'outbound-auth'; const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint'; const isConfirmStep = wizard.step === 'confirm'; const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; @@ -45,16 +73,167 @@ export function AddGatewayTargetScreen({ isActive: isGatewayStep && !noGatewaysAvailable, }); + const outboundAuthNav = useListNavigation({ + items: outboundAuthItems, + onSelect: item => { + const authType = item.id as 'OAUTH' | 'API_KEY' | 'NONE'; + setOutboundAuthTypeLocal(authType); + if (authType === 'NONE') { + wizard.setOutboundAuth({ type: 'NONE' }); + } + }, + onExit: () => wizard.goBack(), + isActive: isOutboundAuthStep && !outboundAuthType, + }); + + const credentialNav = useListNavigation({ + items: credentialItems, + onSelect: item => { + if (item.id === 'create-new') { + setIsCreatingCredential(true); + if (outboundAuthType === 'OAUTH') { + setOauthSubStep('name'); + } else { + setApiKeySubStep('name'); + } + } else { + setCredentialNameLocal(item.id); + wizard.setOutboundAuth({ type: outboundAuthType as 'OAUTH' | 'API_KEY', credentialName: item.id }); + } + }, + onExit: () => { + setOutboundAuthTypeLocal(null); + setCredentialNameLocal(null); + setIsCreatingCredential(false); + }, + isActive: + isOutboundAuthStep && + !!outboundAuthType && + outboundAuthType !== 'NONE' && + !credentialName && + !isCreatingCredential, + }); + useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], onSelect: () => onComplete(wizard.config), - onExit: () => wizard.goBack(), + onExit: () => { + setOutboundAuthTypeLocal(null); + setCredentialNameLocal(null); + setIsCreatingCredential(false); + setOauthSubStep('name'); + setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + setApiKeySubStep('name'); + setApiKeyFields({ name: '', apiKey: '' }); + wizard.goBack(); + }, isActive: isConfirmStep, }); + // OAuth creation handlers + const handleOauthFieldSubmit = (value: string) => { + const newFields = { ...oauthFields }; + + if (oauthSubStep === 'name') { + newFields.name = value; + setOauthFields(newFields); + setOauthSubStep('client-id'); + } else if (oauthSubStep === 'client-id') { + newFields.clientId = value; + setOauthFields(newFields); + setOauthSubStep('client-secret'); + } else if (oauthSubStep === 'client-secret') { + newFields.clientSecret = value; + setOauthFields(newFields); + setOauthSubStep('discovery-url'); + } else if (oauthSubStep === 'discovery-url') { + newFields.discoveryUrl = value; + setOauthFields(newFields); + + // Create the credential + void createIdentity({ + type: 'OAuthCredentialProvider', + name: newFields.name, + clientId: newFields.clientId, + clientSecret: newFields.clientSecret, + discoveryUrl: newFields.discoveryUrl, + }) + .then(result => { + if (result.ok) { + wizard.setOutboundAuth({ type: 'OAUTH', credentialName: newFields.name }); + } else { + setIsCreatingCredential(false); + setOauthSubStep('name'); + setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + } + }) + .catch(() => { + setIsCreatingCredential(false); + setOauthSubStep('name'); + setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + }); + } + }; + + const handleOauthFieldCancel = () => { + if (oauthSubStep === 'name') { + setIsCreatingCredential(false); + setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' }); + } else if (oauthSubStep === 'client-id') { + setOauthSubStep('name'); + } else if (oauthSubStep === 'client-secret') { + setOauthSubStep('client-id'); + } else if (oauthSubStep === 'discovery-url') { + setOauthSubStep('client-secret'); + } + }; + + // API Key creation handlers + const handleApiKeyFieldSubmit = (value: string) => { + const newFields = { ...apiKeyFields }; + + if (apiKeySubStep === 'name') { + newFields.name = value; + setApiKeyFields(newFields); + setApiKeySubStep('api-key'); + } else if (apiKeySubStep === 'api-key') { + newFields.apiKey = value; + setApiKeyFields(newFields); + + void createIdentity({ + type: 'ApiKeyCredentialProvider', + name: newFields.name, + apiKey: newFields.apiKey, + }) + .then(result => { + if (result.ok) { + wizard.setOutboundAuth({ type: 'API_KEY', credentialName: newFields.name }); + } else { + setIsCreatingCredential(false); + setApiKeySubStep('name'); + setApiKeyFields({ name: '', apiKey: '' }); + } + }) + .catch(() => { + setIsCreatingCredential(false); + setApiKeySubStep('name'); + setApiKeyFields({ name: '', apiKey: '' }); + }); + } + }; + + const handleApiKeyFieldCancel = () => { + if (apiKeySubStep === 'name') { + setIsCreatingCredential(false); + setApiKeyFields({ name: '', apiKey: '' }); + } else if (apiKeySubStep === 'api-key') { + setApiKeySubStep('name'); + } + }; + const helpText = isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL - : isTextStep + : isTextStep || isCreatingCredential ? HELP_TEXT.TEXT_INPUT : HELP_TEXT.NAVIGATE_SELECT; @@ -74,6 +253,107 @@ export function AddGatewayTargetScreen({ {noGatewaysAvailable && } + {isOutboundAuthStep && !outboundAuthType && ( + + )} + + {isOutboundAuthStep && + outboundAuthType && + outboundAuthType !== 'NONE' && + !credentialName && + !isCreatingCredential && ( + + )} + + {isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'OAUTH' && ( + <> + {oauthSubStep === 'name' && ( + !existingCredentialNames.includes(value) || 'Credential name already exists'} + /> + )} + {oauthSubStep === 'client-id' && ( + value.trim().length > 0 || 'Client ID is required'} + /> + )} + {oauthSubStep === 'client-secret' && ( + value.trim().length > 0 || 'Client secret is required'} + revealChars={4} + /> + )} + {oauthSubStep === 'discovery-url' && ( + { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return 'Discovery URL must use http:// or https:// protocol'; + } + return true; + } catch { + return 'Must be a valid URL'; + } + }} + /> + )} + + )} + + {isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'API_KEY' && ( + <> + {apiKeySubStep === 'name' && ( + !existingCredentialNames.includes(value) || 'Credential name already exists'} + /> + )} + {apiKeySubStep === 'api-key' && ( + value.trim().length > 0 || 'API key is required'} + revealChars={4} + /> + )} + + )} + {isTextStep && ( )} diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 0c5e3689..fcf7d593 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -43,7 +43,15 @@ export type ComputeHost = 'Lambda' | 'AgentCoreRuntime'; * - host: Select compute host * - confirm: Review and confirm */ -export type AddGatewayTargetStep = 'name' | 'source' | 'endpoint' | 'language' | 'gateway' | 'host' | 'confirm'; +export type AddGatewayTargetStep = + | 'name' + | 'source' + | 'endpoint' + | 'language' + | 'gateway' + | 'host' + | 'outbound-auth' + | 'confirm'; export type TargetLanguage = 'Python' | 'TypeScript' | 'Other'; @@ -77,6 +85,7 @@ export const MCP_TOOL_STEP_LABELS: Record = { language: 'Language', gateway: 'Gateway', host: 'Host', + 'outbound-auth': 'Outbound Auth', confirm: 'Confirm', }; @@ -108,6 +117,11 @@ export const COMPUTE_HOST_OPTIONS = [ { id: 'AgentCoreRuntime', title: 'AgentCore Runtime', description: 'AgentCore Runtime (Python only)' }, ] as const; +export const OUTBOUND_AUTH_OPTIONS = [ + { id: 'NONE', title: 'No authorization', description: 'No outbound authentication' }, + { id: 'OAUTH', title: 'OAuth 2LO', description: 'OAuth 2.0 client credentials' }, +] as const; + export const PYTHON_VERSION_OPTIONS = [ { id: 'PYTHON_3_13', title: 'Python 3.13', description: 'Latest' }, { id: 'PYTHON_3_12', title: 'Python 3.12', description: '' }, diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index 9a60cc7d..71a96fe6 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -6,10 +6,10 @@ import { useCallback, useMemo, useState } from 'react'; /** * Steps for adding a gateway target (existing endpoint only). - * name → endpoint → gateway → confirm + * name → endpoint → gateway → outbound-auth → confirm */ function getSteps(): AddGatewayTargetStep[] { - return ['name', 'endpoint', 'gateway', 'confirm']; + return ['name', 'endpoint', 'gateway', 'outbound-auth', 'confirm']; } function deriveToolDefinition(name: string): ToolDefinition { @@ -68,11 +68,22 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { const setGateway = useCallback((gateway: string) => { setConfig(c => { const isSkipped = gateway === SKIP_FOR_NOW; - setStep('confirm'); return { ...c, gateway: isSkipped ? undefined : gateway }; }); + setStep('outbound-auth'); }, []); + const setOutboundAuth = useCallback( + (outboundAuth: { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string }) => { + setConfig(c => ({ + ...c, + outboundAuth, + })); + setStep('confirm'); + }, + [] + ); + const reset = useCallback(() => { setConfig(getDefaultConfig()); setStep('name'); @@ -88,6 +99,7 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { setName, setEndpoint, setGateway, + setOutboundAuth, reset, }; }