Skip to content

Commit 5d9d710

Browse files
authored
feat: add outbound auth wizard step with inline credential creation (#417)
1 parent d28b08c commit 5d9d710

3 files changed

Lines changed: 321 additions & 16 deletions

File tree

src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx

Lines changed: 291 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { ToolNameSchema } from '../../../../schema';
2-
import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components';
2+
import { ConfirmReview, Panel, Screen, SecretInput, StepIndicator, TextInput, WizardSelect } from '../../components';
33
import type { SelectableItem } from '../../components';
44
import { HELP_TEXT } from '../../constants';
55
import { useListNavigation } from '../../hooks';
66
import { generateUniqueName } from '../../utils';
7+
import { useCreateIdentity, useExistingCredentialNames } from '../identity/useCreateIdentity.js';
78
import type { AddGatewayTargetConfig } from './types';
8-
import { MCP_TOOL_STEP_LABELS, SKIP_FOR_NOW } from './types';
9+
import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS, SKIP_FOR_NOW } from './types';
910
import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard';
1011
import { Box, Text } from 'ink';
11-
import React, { useMemo } from 'react';
12+
import React, { useMemo, useState } from 'react';
1213

1314
interface AddGatewayTargetScreenProps {
1415
existingGateways: string[];
@@ -24,6 +25,17 @@ export function AddGatewayTargetScreen({
2425
onExit,
2526
}: AddGatewayTargetScreenProps) {
2627
const wizard = useAddGatewayTargetWizard(existingGateways);
28+
const { names: existingCredentialNames } = useExistingCredentialNames();
29+
const { createIdentity } = useCreateIdentity();
30+
31+
// Outbound auth sub-step state
32+
const [outboundAuthType, setOutboundAuthTypeLocal] = useState<string | null>(null);
33+
const [credentialName, setCredentialNameLocal] = useState<string | null>(null);
34+
const [isCreatingCredential, setIsCreatingCredential] = useState(false);
35+
const [oauthSubStep, setOauthSubStep] = useState<'name' | 'client-id' | 'client-secret' | 'discovery-url'>('name');
36+
const [oauthFields, setOauthFields] = useState({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' });
37+
const [apiKeySubStep, setApiKeySubStep] = useState<'name' | 'api-key'>('name');
38+
const [apiKeyFields, setApiKeyFields] = useState({ name: '', apiKey: '' });
2739

2840
const gatewayItems: SelectableItem[] = useMemo(
2941
() => [
@@ -33,7 +45,23 @@ export function AddGatewayTargetScreen({
3345
[existingGateways]
3446
);
3547

48+
const outboundAuthItems: SelectableItem[] = useMemo(
49+
() => OUTBOUND_AUTH_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
50+
[]
51+
);
52+
53+
const credentialItems: SelectableItem[] = useMemo(() => {
54+
const items: SelectableItem[] = [
55+
{ id: 'create-new', title: 'Create new credential', description: 'Create a new credential inline' },
56+
];
57+
existingCredentialNames.forEach(name => {
58+
items.push({ id: name, title: name, description: 'Use existing credential' });
59+
});
60+
return items;
61+
}, [existingCredentialNames]);
62+
3663
const isGatewayStep = wizard.step === 'gateway';
64+
const isOutboundAuthStep = wizard.step === 'outbound-auth';
3765
const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint';
3866
const isConfirmStep = wizard.step === 'confirm';
3967
const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0;
@@ -45,16 +73,167 @@ export function AddGatewayTargetScreen({
4573
isActive: isGatewayStep && !noGatewaysAvailable,
4674
});
4775

76+
const outboundAuthNav = useListNavigation({
77+
items: outboundAuthItems,
78+
onSelect: item => {
79+
const authType = item.id as 'OAUTH' | 'API_KEY' | 'NONE';
80+
setOutboundAuthTypeLocal(authType);
81+
if (authType === 'NONE') {
82+
wizard.setOutboundAuth({ type: 'NONE' });
83+
}
84+
},
85+
onExit: () => wizard.goBack(),
86+
isActive: isOutboundAuthStep && !outboundAuthType,
87+
});
88+
89+
const credentialNav = useListNavigation({
90+
items: credentialItems,
91+
onSelect: item => {
92+
if (item.id === 'create-new') {
93+
setIsCreatingCredential(true);
94+
if (outboundAuthType === 'OAUTH') {
95+
setOauthSubStep('name');
96+
} else {
97+
setApiKeySubStep('name');
98+
}
99+
} else {
100+
setCredentialNameLocal(item.id);
101+
wizard.setOutboundAuth({ type: outboundAuthType as 'OAUTH' | 'API_KEY', credentialName: item.id });
102+
}
103+
},
104+
onExit: () => {
105+
setOutboundAuthTypeLocal(null);
106+
setCredentialNameLocal(null);
107+
setIsCreatingCredential(false);
108+
},
109+
isActive:
110+
isOutboundAuthStep &&
111+
!!outboundAuthType &&
112+
outboundAuthType !== 'NONE' &&
113+
!credentialName &&
114+
!isCreatingCredential,
115+
});
116+
48117
useListNavigation({
49118
items: [{ id: 'confirm', title: 'Confirm' }],
50119
onSelect: () => onComplete(wizard.config),
51-
onExit: () => wizard.goBack(),
120+
onExit: () => {
121+
setOutboundAuthTypeLocal(null);
122+
setCredentialNameLocal(null);
123+
setIsCreatingCredential(false);
124+
setOauthSubStep('name');
125+
setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' });
126+
setApiKeySubStep('name');
127+
setApiKeyFields({ name: '', apiKey: '' });
128+
wizard.goBack();
129+
},
52130
isActive: isConfirmStep,
53131
});
54132

133+
// OAuth creation handlers
134+
const handleOauthFieldSubmit = (value: string) => {
135+
const newFields = { ...oauthFields };
136+
137+
if (oauthSubStep === 'name') {
138+
newFields.name = value;
139+
setOauthFields(newFields);
140+
setOauthSubStep('client-id');
141+
} else if (oauthSubStep === 'client-id') {
142+
newFields.clientId = value;
143+
setOauthFields(newFields);
144+
setOauthSubStep('client-secret');
145+
} else if (oauthSubStep === 'client-secret') {
146+
newFields.clientSecret = value;
147+
setOauthFields(newFields);
148+
setOauthSubStep('discovery-url');
149+
} else if (oauthSubStep === 'discovery-url') {
150+
newFields.discoveryUrl = value;
151+
setOauthFields(newFields);
152+
153+
// Create the credential
154+
void createIdentity({
155+
type: 'OAuthCredentialProvider',
156+
name: newFields.name,
157+
clientId: newFields.clientId,
158+
clientSecret: newFields.clientSecret,
159+
discoveryUrl: newFields.discoveryUrl,
160+
})
161+
.then(result => {
162+
if (result.ok) {
163+
wizard.setOutboundAuth({ type: 'OAUTH', credentialName: newFields.name });
164+
} else {
165+
setIsCreatingCredential(false);
166+
setOauthSubStep('name');
167+
setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' });
168+
}
169+
})
170+
.catch(() => {
171+
setIsCreatingCredential(false);
172+
setOauthSubStep('name');
173+
setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' });
174+
});
175+
}
176+
};
177+
178+
const handleOauthFieldCancel = () => {
179+
if (oauthSubStep === 'name') {
180+
setIsCreatingCredential(false);
181+
setOauthFields({ name: '', clientId: '', clientSecret: '', discoveryUrl: '' });
182+
} else if (oauthSubStep === 'client-id') {
183+
setOauthSubStep('name');
184+
} else if (oauthSubStep === 'client-secret') {
185+
setOauthSubStep('client-id');
186+
} else if (oauthSubStep === 'discovery-url') {
187+
setOauthSubStep('client-secret');
188+
}
189+
};
190+
191+
// API Key creation handlers
192+
const handleApiKeyFieldSubmit = (value: string) => {
193+
const newFields = { ...apiKeyFields };
194+
195+
if (apiKeySubStep === 'name') {
196+
newFields.name = value;
197+
setApiKeyFields(newFields);
198+
setApiKeySubStep('api-key');
199+
} else if (apiKeySubStep === 'api-key') {
200+
newFields.apiKey = value;
201+
setApiKeyFields(newFields);
202+
203+
void createIdentity({
204+
type: 'ApiKeyCredentialProvider',
205+
name: newFields.name,
206+
apiKey: newFields.apiKey,
207+
})
208+
.then(result => {
209+
if (result.ok) {
210+
wizard.setOutboundAuth({ type: 'API_KEY', credentialName: newFields.name });
211+
} else {
212+
setIsCreatingCredential(false);
213+
setApiKeySubStep('name');
214+
setApiKeyFields({ name: '', apiKey: '' });
215+
}
216+
})
217+
.catch(() => {
218+
setIsCreatingCredential(false);
219+
setApiKeySubStep('name');
220+
setApiKeyFields({ name: '', apiKey: '' });
221+
});
222+
}
223+
};
224+
225+
const handleApiKeyFieldCancel = () => {
226+
if (apiKeySubStep === 'name') {
227+
setIsCreatingCredential(false);
228+
setApiKeyFields({ name: '', apiKey: '' });
229+
} else if (apiKeySubStep === 'api-key') {
230+
setApiKeySubStep('name');
231+
}
232+
};
233+
55234
const helpText = isConfirmStep
56235
? HELP_TEXT.CONFIRM_CANCEL
57-
: isTextStep
236+
: isTextStep || isCreatingCredential
58237
? HELP_TEXT.TEXT_INPUT
59238
: HELP_TEXT.NAVIGATE_SELECT;
60239

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

75254
{noGatewaysAvailable && <NoGatewaysMessage />}
76255

256+
{isOutboundAuthStep && !outboundAuthType && (
257+
<WizardSelect
258+
title="Select outbound authentication"
259+
description="How will this tool authenticate to external services?"
260+
items={outboundAuthItems}
261+
selectedIndex={outboundAuthNav.selectedIndex}
262+
/>
263+
)}
264+
265+
{isOutboundAuthStep &&
266+
outboundAuthType &&
267+
outboundAuthType !== 'NONE' &&
268+
!credentialName &&
269+
!isCreatingCredential && (
270+
<WizardSelect
271+
title="Select credential"
272+
description={`Choose a credential for ${outboundAuthType} authentication`}
273+
items={credentialItems}
274+
selectedIndex={credentialNav.selectedIndex}
275+
/>
276+
)}
277+
278+
{isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'OAUTH' && (
279+
<>
280+
{oauthSubStep === 'name' && (
281+
<TextInput
282+
key="oauth-name"
283+
prompt="Credential name"
284+
initialValue={generateUniqueName('MyOAuth', existingCredentialNames)}
285+
onSubmit={handleOauthFieldSubmit}
286+
onCancel={handleOauthFieldCancel}
287+
customValidation={value => !existingCredentialNames.includes(value) || 'Credential name already exists'}
288+
/>
289+
)}
290+
{oauthSubStep === 'client-id' && (
291+
<TextInput
292+
key="oauth-client-id"
293+
prompt="Client ID"
294+
onSubmit={handleOauthFieldSubmit}
295+
onCancel={handleOauthFieldCancel}
296+
customValidation={value => value.trim().length > 0 || 'Client ID is required'}
297+
/>
298+
)}
299+
{oauthSubStep === 'client-secret' && (
300+
<SecretInput
301+
key="oauth-client-secret"
302+
prompt="Client Secret"
303+
onSubmit={handleOauthFieldSubmit}
304+
onCancel={handleOauthFieldCancel}
305+
customValidation={value => value.trim().length > 0 || 'Client secret is required'}
306+
revealChars={4}
307+
/>
308+
)}
309+
{oauthSubStep === 'discovery-url' && (
310+
<TextInput
311+
key="oauth-discovery-url"
312+
prompt="Discovery URL"
313+
placeholder="https://example.com/.well-known/openid_configuration"
314+
onSubmit={handleOauthFieldSubmit}
315+
onCancel={handleOauthFieldCancel}
316+
customValidation={value => {
317+
try {
318+
const url = new URL(value);
319+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
320+
return 'Discovery URL must use http:// or https:// protocol';
321+
}
322+
return true;
323+
} catch {
324+
return 'Must be a valid URL';
325+
}
326+
}}
327+
/>
328+
)}
329+
</>
330+
)}
331+
332+
{isOutboundAuthStep && isCreatingCredential && outboundAuthType === 'API_KEY' && (
333+
<>
334+
{apiKeySubStep === 'name' && (
335+
<TextInput
336+
key="apikey-name"
337+
prompt="Credential name"
338+
initialValue={generateUniqueName('MyApiKey', existingCredentialNames)}
339+
onSubmit={handleApiKeyFieldSubmit}
340+
onCancel={handleApiKeyFieldCancel}
341+
customValidation={value => !existingCredentialNames.includes(value) || 'Credential name already exists'}
342+
/>
343+
)}
344+
{apiKeySubStep === 'api-key' && (
345+
<SecretInput
346+
key="apikey-value"
347+
prompt="API Key"
348+
onSubmit={handleApiKeyFieldSubmit}
349+
onCancel={handleApiKeyFieldCancel}
350+
customValidation={value => value.trim().length > 0 || 'API key is required'}
351+
revealChars={4}
352+
/>
353+
)}
354+
</>
355+
)}
356+
77357
{isTextStep && (
78358
<TextInput
79359
key={wizard.step}
@@ -107,16 +387,15 @@ export function AddGatewayTargetScreen({
107387
<ConfirmReview
108388
fields={[
109389
{ label: 'Name', value: wizard.config.name },
110-
{
111-
label: 'Source',
112-
value: wizard.config.source === 'existing-endpoint' ? 'Existing endpoint' : 'Create new',
113-
},
114390
...(wizard.config.endpoint ? [{ label: 'Endpoint', value: wizard.config.endpoint }] : []),
115-
...(wizard.config.source === 'create-new' ? [{ label: 'Language', value: wizard.config.language }] : []),
116391
...(wizard.config.gateway ? [{ label: 'Gateway', value: wizard.config.gateway }] : []),
117392
...(!wizard.config.gateway ? [{ label: 'Gateway', value: '(none - assign later)' }] : []),
118-
...(wizard.config.source === 'create-new' ? [{ label: 'Host', value: wizard.config.host }] : []),
119-
...(wizard.config.source === 'create-new' ? [{ label: 'Source', value: wizard.config.sourcePath }] : []),
393+
...(wizard.config.outboundAuth
394+
? [
395+
{ label: 'Auth Type', value: wizard.config.outboundAuth.type },
396+
{ label: 'Credential', value: wizard.config.outboundAuth.credentialName ?? 'None' },
397+
]
398+
: []),
120399
]}
121400
/>
122401
)}

src/cli/tui/screens/mcp/types.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,15 @@ export type ComputeHost = 'Lambda' | 'AgentCoreRuntime';
4343
* - host: Select compute host
4444
* - confirm: Review and confirm
4545
*/
46-
export type AddGatewayTargetStep = 'name' | 'source' | 'endpoint' | 'language' | 'gateway' | 'host' | 'confirm';
46+
export type AddGatewayTargetStep =
47+
| 'name'
48+
| 'source'
49+
| 'endpoint'
50+
| 'language'
51+
| 'gateway'
52+
| 'host'
53+
| 'outbound-auth'
54+
| 'confirm';
4755

4856
export type TargetLanguage = 'Python' | 'TypeScript' | 'Other';
4957

@@ -77,6 +85,7 @@ export const MCP_TOOL_STEP_LABELS: Record<AddGatewayTargetStep, string> = {
7785
language: 'Language',
7886
gateway: 'Gateway',
7987
host: 'Host',
88+
'outbound-auth': 'Outbound Auth',
8089
confirm: 'Confirm',
8190
};
8291

@@ -108,6 +117,11 @@ export const COMPUTE_HOST_OPTIONS = [
108117
{ id: 'AgentCoreRuntime', title: 'AgentCore Runtime', description: 'AgentCore Runtime (Python only)' },
109118
] as const;
110119

120+
export const OUTBOUND_AUTH_OPTIONS = [
121+
{ id: 'NONE', title: 'No authorization', description: 'No outbound authentication' },
122+
{ id: 'OAUTH', title: 'OAuth 2LO', description: 'OAuth 2.0 client credentials' },
123+
] as const;
124+
111125
export const PYTHON_VERSION_OPTIONS = [
112126
{ id: 'PYTHON_3_13', title: 'Python 3.13', description: 'Latest' },
113127
{ id: 'PYTHON_3_12', title: 'Python 3.12', description: '' },

0 commit comments

Comments
 (0)