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
303 changes: 291 additions & 12 deletions src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand All @@ -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<string | null>(null);
const [credentialName, setCredentialNameLocal] = useState<string | null>(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(
() => [
Expand All @@ -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;
Expand All @@ -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;

Expand All @@ -74,6 +253,107 @@ export function AddGatewayTargetScreen({

{noGatewaysAvailable && <NoGatewaysMessage />}

{isOutboundAuthStep && !outboundAuthType && (
<WizardSelect
title="Select outbound authentication"
description="How will this tool authenticate to external services?"
items={outboundAuthItems}
selectedIndex={outboundAuthNav.selectedIndex}
/>
)}

{isOutboundAuthStep &&
outboundAuthType &&
outboundAuthType !== 'NONE' &&
!credentialName &&
!isCreatingCredential && (
<WizardSelect
title="Select credential"
description={`Choose a credential for ${outboundAuthType} authentication`}
items={credentialItems}
selectedIndex={credentialNav.selectedIndex}
/>
)}

{isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'OAUTH' && (
<>
{oauthSubStep === 'name' && (
<TextInput
key="oauth-name"
prompt="Credential name"
initialValue={generateUniqueName('MyOAuth', existingCredentialNames)}
onSubmit={handleOauthFieldSubmit}
onCancel={handleOauthFieldCancel}
customValidation={value => !existingCredentialNames.includes(value) || 'Credential name already exists'}
/>
)}
{oauthSubStep === 'client-id' && (
<TextInput
key="oauth-client-id"
prompt="Client ID"
onSubmit={handleOauthFieldSubmit}
onCancel={handleOauthFieldCancel}
customValidation={value => value.trim().length > 0 || 'Client ID is required'}
/>
)}
{oauthSubStep === 'client-secret' && (
<SecretInput
key="oauth-client-secret"
prompt="Client Secret"
onSubmit={handleOauthFieldSubmit}
onCancel={handleOauthFieldCancel}
customValidation={value => value.trim().length > 0 || 'Client secret is required'}
revealChars={4}
/>
)}
{oauthSubStep === 'discovery-url' && (
<TextInput
key="oauth-discovery-url"
prompt="Discovery URL"
placeholder="https://example.com/.well-known/openid_configuration"
onSubmit={handleOauthFieldSubmit}
onCancel={handleOauthFieldCancel}
customValidation={value => {
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' && (
<TextInput
key="apikey-name"
prompt="Credential name"
initialValue={generateUniqueName('MyApiKey', existingCredentialNames)}
onSubmit={handleApiKeyFieldSubmit}
onCancel={handleApiKeyFieldCancel}
customValidation={value => !existingCredentialNames.includes(value) || 'Credential name already exists'}
/>
)}
{apiKeySubStep === 'api-key' && (
<SecretInput
key="apikey-value"
prompt="API Key"
onSubmit={handleApiKeyFieldSubmit}
onCancel={handleApiKeyFieldCancel}
customValidation={value => value.trim().length > 0 || 'API key is required'}
revealChars={4}
/>
)}
</>
)}

{isTextStep && (
<TextInput
key={wizard.step}
Expand Down Expand Up @@ -107,16 +387,15 @@ export function AddGatewayTargetScreen({
<ConfirmReview
fields={[
{ label: 'Name', value: wizard.config.name },
{
label: 'Source',
value: wizard.config.source === 'existing-endpoint' ? 'Existing endpoint' : 'Create new',
},
...(wizard.config.endpoint ? [{ label: 'Endpoint', value: wizard.config.endpoint }] : []),
...(wizard.config.source === 'create-new' ? [{ label: 'Language', value: wizard.config.language }] : []),
...(wizard.config.gateway ? [{ label: 'Gateway', value: wizard.config.gateway }] : []),
...(!wizard.config.gateway ? [{ label: 'Gateway', value: '(none - assign later)' }] : []),
...(wizard.config.source === 'create-new' ? [{ label: 'Host', value: wizard.config.host }] : []),
...(wizard.config.source === 'create-new' ? [{ label: 'Source', value: wizard.config.sourcePath }] : []),
...(wizard.config.outboundAuth
? [
{ label: 'Auth Type', value: wizard.config.outboundAuth.type },
{ label: 'Credential', value: wizard.config.outboundAuth.credentialName ?? 'None' },
]
: []),
]}
/>
)}
Expand Down
16 changes: 15 additions & 1 deletion src/cli/tui/screens/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -77,6 +85,7 @@ export const MCP_TOOL_STEP_LABELS: Record<AddGatewayTargetStep, string> = {
language: 'Language',
gateway: 'Gateway',
host: 'Host',
'outbound-auth': 'Outbound Auth',
confirm: 'Confirm',
};

Expand Down Expand Up @@ -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: '' },
Expand Down
Loading
Loading