Skip to content

Commit 0e96f8e

Browse files
authored
feat: add external MCP server target support and unassigned targets (#406)
1 parent e832292 commit 0e96f8e

File tree

10 files changed

+261
-38
lines changed

10 files changed

+261
-38
lines changed

src/cli/commands/add/actions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ export interface ValidatedAddGatewayOptions {
6666
export interface ValidatedAddGatewayTargetOptions {
6767
name: string;
6868
description?: string;
69+
type?: string;
70+
source?: 'existing-endpoint' | 'create-new';
71+
endpoint?: string;
6972
language: 'Python' | 'TypeScript' | 'Other';
7073
exposure: 'mcp-runtime' | 'behind-gateway';
7174
agents?: string;
@@ -304,6 +307,8 @@ function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): Ad
304307
sourcePath,
305308
language: options.language,
306309
exposure: options.exposure,
310+
source: options.source,
311+
endpoint: options.endpoint,
307312
host: options.exposure === 'mcp-runtime' ? 'AgentCoreRuntime' : options.host!,
308313
toolDefinition: {
309314
name: options.name,

src/cli/commands/add/command.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ export function registerAdd(program: Command) {
262262
.description('Add a gateway target to the project')
263263
.option('--name <name>', 'Tool name')
264264
.option('--description <desc>', 'Tool description')
265+
.option('--type <type>', 'Target type: mcpServer or lambda')
266+
.option('--source <source>', 'Source: existing-endpoint or create-new')
267+
.option('--endpoint <url>', 'MCP server endpoint URL')
265268
.option('--language <lang>', 'Language: Python or TypeScript')
266269
.option('--exposure <mode>', 'Exposure mode: mcp-runtime or behind-gateway')
267270
.option('--agents <names>', 'Comma-separated agent names (for mcp-runtime)')

src/cli/commands/add/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export interface AddGatewayResult {
4545
export interface AddGatewayTargetOptions {
4646
name?: string;
4747
description?: string;
48+
type?: string;
49+
source?: string;
50+
endpoint?: string;
4851
language?: 'Python' | 'TypeScript' | 'Other';
4952
exposure?: 'mcp-runtime' | 'behind-gateway';
5053
agents?: string;

src/cli/commands/add/validate.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,36 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO
189189
return { valid: false, error: '--name is required' };
190190
}
191191

192+
if (options.type && options.type !== 'mcpServer' && options.type !== 'lambda') {
193+
return { valid: false, error: 'Invalid type. Valid options: mcpServer, lambda' };
194+
}
195+
196+
if (options.source && options.source !== 'existing-endpoint' && options.source !== 'create-new') {
197+
return { valid: false, error: 'Invalid source. Valid options: existing-endpoint, create-new' };
198+
}
199+
200+
if (options.source === 'existing-endpoint') {
201+
if (!options.endpoint) {
202+
return { valid: false, error: '--endpoint is required when source is existing-endpoint' };
203+
}
204+
205+
try {
206+
const url = new URL(options.endpoint);
207+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
208+
return { valid: false, error: 'Endpoint must use http:// or https:// protocol' };
209+
}
210+
} catch {
211+
return { valid: false, error: 'Endpoint must be a valid URL (e.g. https://example.com/mcp)' };
212+
}
213+
214+
// Populate defaults for fields skipped by external endpoint flow
215+
options.language ??= 'Other';
216+
options.exposure ??= 'behind-gateway';
217+
options.gateway ??= undefined;
218+
219+
return { valid: true };
220+
}
221+
192222
if (!options.language) {
193223
return { valid: false, error: '--language is required' };
194224
}

src/cli/operations/mcp/create-mcp.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import type {
1212
import { AgentCoreCliMcpDefsSchema, ToolDefinitionSchema } from '../../../schema';
1313
import { getTemplateToolDefinitions, renderGatewayTargetTemplate } from '../../templates/GatewayTargetRenderer';
1414
import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../tui/screens/mcp/types';
15-
import { DEFAULT_HANDLER, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION } from '../../tui/screens/mcp/types';
15+
import {
16+
DEFAULT_HANDLER,
17+
DEFAULT_NODE_VERSION,
18+
DEFAULT_PYTHON_VERSION,
19+
SKIP_FOR_NOW,
20+
} from '../../tui/screens/mcp/types';
1621
import { existsSync } from 'fs';
1722
import { mkdir, readFile, writeFile } from 'fs/promises';
1823
import { dirname, join } from 'path';
@@ -198,6 +203,57 @@ async function validateCredentialName(credentialName: string): Promise<void> {
198203
}
199204
}
200205

206+
/**
207+
* Create an external MCP server target (existing endpoint).
208+
*/
209+
export async function createExternalGatewayTarget(config: AddGatewayTargetConfig): Promise<CreateToolResult> {
210+
if (!config.endpoint) {
211+
throw new Error('Endpoint URL is required for external MCP server targets.');
212+
}
213+
214+
const configIO = new ConfigIO();
215+
const mcpSpec: AgentCoreMcpSpec = configIO.configExists('mcp')
216+
? await configIO.readMcpSpec()
217+
: { agentCoreGateways: [], unassignedTargets: [] };
218+
219+
const target: AgentCoreGatewayTarget = {
220+
name: config.name,
221+
targetType: 'mcpServer',
222+
endpoint: config.endpoint,
223+
toolDefinitions: [config.toolDefinition],
224+
...(config.outboundAuth && { outboundAuth: config.outboundAuth }),
225+
};
226+
227+
if (config.gateway && config.gateway !== SKIP_FOR_NOW) {
228+
// Assign to specific gateway
229+
const gateway = mcpSpec.agentCoreGateways.find(g => g.name === config.gateway);
230+
if (!gateway) {
231+
throw new Error(`Gateway "${config.gateway}" not found.`);
232+
}
233+
234+
// Check for duplicate target name
235+
if (gateway.targets.some(t => t.name === config.name)) {
236+
throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`);
237+
}
238+
239+
gateway.targets.push(target);
240+
} else {
241+
// Add to unassigned targets
242+
mcpSpec.unassignedTargets ??= [];
243+
244+
// Check for duplicate target name in unassigned targets
245+
if (mcpSpec.unassignedTargets.some((t: AgentCoreGatewayTarget) => t.name === config.name)) {
246+
throw new Error(`Unassigned target "${config.name}" already exists.`);
247+
}
248+
249+
mcpSpec.unassignedTargets.push(target);
250+
}
251+
252+
await configIO.writeMcpSpec(mcpSpec);
253+
254+
return { mcpDefsPath: '', toolName: config.name, projectPath: '' };
255+
}
256+
201257
/**
202258
* Create an MCP tool (MCP runtime or behind gateway).
203259
*/

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

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createExternalGatewayTarget } from '../../../operations/mcp/create-mcp';
12
import { ErrorPrompt, Panel, Screen, TextInput, WizardSelect } from '../../components';
23
import type { SelectableItem } from '../../components';
34
import { HELP_TEXT } from '../../constants';
@@ -114,14 +115,25 @@ export function AddGatewayTargetFlow({
114115
loading: true,
115116
loadingMessage: 'Creating MCP tool...',
116117
});
117-
void createTool(config).then(result => {
118-
if (result.ok) {
119-
const { toolName, projectPath } = result.result;
120-
setFlow({ name: 'create-success', toolName, projectPath });
121-
return;
122-
}
123-
setFlow({ name: 'error', message: result.error });
124-
});
118+
119+
if (config.source === 'existing-endpoint') {
120+
void createExternalGatewayTarget(config)
121+
.then((result: { toolName: string; projectPath: string }) => {
122+
setFlow({ name: 'create-success', toolName: result.toolName, projectPath: result.projectPath });
123+
})
124+
.catch((err: unknown) => {
125+
setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' });
126+
});
127+
} else {
128+
void createTool(config).then(result => {
129+
if (result.ok) {
130+
const { toolName, projectPath } = result.result;
131+
setFlow({ name: 'create-success', toolName, projectPath });
132+
return;
133+
}
134+
setFlow({ name: 'error', message: result.error });
135+
});
136+
}
125137
},
126138
[createTool]
127139
);

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

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ import { HELP_TEXT } from '../../constants';
1313
import { useListNavigation, useMultiSelectNavigation } from '../../hooks';
1414
import { generateUniqueName } from '../../utils';
1515
import type { AddGatewayTargetConfig, ComputeHost, ExposureMode, TargetLanguage } from './types';
16-
import { COMPUTE_HOST_OPTIONS, EXPOSURE_MODE_OPTIONS, MCP_TOOL_STEP_LABELS, TARGET_LANGUAGE_OPTIONS } from './types';
16+
import {
17+
COMPUTE_HOST_OPTIONS,
18+
EXPOSURE_MODE_OPTIONS,
19+
MCP_TOOL_STEP_LABELS,
20+
SKIP_FOR_NOW,
21+
SOURCE_OPTIONS,
22+
TARGET_LANGUAGE_OPTIONS,
23+
} from './types';
1724
import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard';
1825
import { Box, Text } from 'ink';
1926
import React, { useMemo } from 'react';
@@ -35,6 +42,11 @@ export function AddGatewayTargetScreen({
3542
}: AddGatewayTargetScreenProps) {
3643
const wizard = useAddGatewayTargetWizard(existingGateways, existingAgents);
3744

45+
const sourceItems: SelectableItem[] = useMemo(
46+
() => SOURCE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
47+
[]
48+
);
49+
3850
const languageItems: SelectableItem[] = useMemo(
3951
() => TARGET_LANGUAGE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
4052
[]
@@ -52,7 +64,10 @@ export function AddGatewayTargetScreen({
5264
);
5365

5466
const gatewayItems: SelectableItem[] = useMemo(
55-
() => existingGateways.map(g => ({ id: g, title: g })),
67+
() => [
68+
...existingGateways.map(g => ({ id: g, title: g })),
69+
{ id: SKIP_FOR_NOW, title: 'Skip for now', description: 'Create unassigned target' },
70+
],
5671
[existingGateways]
5772
);
5873

@@ -63,16 +78,24 @@ export function AddGatewayTargetScreen({
6378

6479
const agentItems: SelectableItem[] = useMemo(() => existingAgents.map(a => ({ id: a, title: a })), [existingAgents]);
6580

81+
const isSourceStep = wizard.step === 'source';
6682
const isLanguageStep = wizard.step === 'language';
6783
const isExposureStep = wizard.step === 'exposure';
6884
const isAgentsStep = wizard.step === 'agents';
6985
const isGatewayStep = wizard.step === 'gateway';
7086
const isHostStep = wizard.step === 'host';
71-
const isTextStep = wizard.step === 'name';
87+
const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint';
7288
const isConfirmStep = wizard.step === 'confirm';
7389
const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0;
7490
const noAgentsAvailable = isAgentsStep && existingAgents.length === 0;
7591

92+
const sourceNav = useListNavigation({
93+
items: sourceItems,
94+
onSelect: item => wizard.setSource(item.id as 'existing-endpoint' | 'create-new'),
95+
onExit: () => wizard.goBack(),
96+
isActive: isSourceStep,
97+
});
98+
7699
const languageNav = useListNavigation({
77100
items: languageItems,
78101
onSelect: item => wizard.setLanguage(item.id as TargetLanguage),
@@ -132,6 +155,15 @@ export function AddGatewayTargetScreen({
132155
return (
133156
<Screen title="Add MCP Tool" onExit={onExit} helpText={helpText} headerContent={headerContent}>
134157
<Panel>
158+
{isSourceStep && (
159+
<WizardSelect
160+
title="Select source"
161+
description="How would you like to create this MCP tool?"
162+
items={sourceItems}
163+
selectedIndex={sourceNav.selectedIndex}
164+
/>
165+
)}
166+
135167
{isLanguageStep && (
136168
<WizardSelect title="Select language" items={languageItems} selectedIndex={languageNav.selectedIndex} />
137169
)}
@@ -180,27 +212,54 @@ export function AddGatewayTargetScreen({
180212
{isTextStep && (
181213
<TextInput
182214
key={wizard.step}
183-
prompt={MCP_TOOL_STEP_LABELS[wizard.step]}
184-
initialValue={generateUniqueName('mytool', existingToolNames)}
185-
onSubmit={wizard.setName}
215+
prompt={wizard.step === 'endpoint' ? 'MCP server endpoint URL' : MCP_TOOL_STEP_LABELS[wizard.step]}
216+
initialValue={wizard.step === 'endpoint' ? undefined : generateUniqueName('mytool', existingToolNames)}
217+
placeholder={wizard.step === 'endpoint' ? 'https://example.com/mcp' : undefined}
218+
onSubmit={wizard.step === 'endpoint' ? wizard.setEndpoint : wizard.setName}
186219
onCancel={() => (wizard.currentIndex === 0 ? onExit() : wizard.goBack())}
187-
schema={ToolNameSchema}
188-
customValidation={value => !existingToolNames.includes(value) || 'Tool name already exists'}
220+
schema={wizard.step === 'name' ? ToolNameSchema : undefined}
221+
customValidation={
222+
wizard.step === 'name'
223+
? value => !existingToolNames.includes(value) || 'Tool name already exists'
224+
: wizard.step === 'endpoint'
225+
? value => {
226+
try {
227+
const url = new URL(value);
228+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
229+
return 'Endpoint must use http:// or https:// protocol';
230+
}
231+
return true;
232+
} catch {
233+
return 'Must be a valid URL (e.g. https://example.com/mcp)';
234+
}
235+
}
236+
: undefined
237+
}
189238
/>
190239
)}
191240

192241
{isConfirmStep && (
193242
<ConfirmReview
194243
fields={[
195244
{ label: 'Name', value: wizard.config.name },
196-
{ label: 'Language', value: wizard.config.language },
197-
{ label: 'Exposure', value: isMcpRuntime ? 'MCP Runtime' : 'Behind Gateway' },
245+
{
246+
label: 'Source',
247+
value: wizard.config.source === 'existing-endpoint' ? 'Existing endpoint' : 'Create new',
248+
},
249+
...(wizard.config.endpoint ? [{ label: 'Endpoint', value: wizard.config.endpoint }] : []),
250+
...(wizard.config.source === 'create-new' ? [{ label: 'Language', value: wizard.config.language }] : []),
251+
...(wizard.config.source === 'create-new'
252+
? [{ label: 'Exposure', value: isMcpRuntime ? 'MCP Runtime' : 'Behind Gateway' }]
253+
: []),
198254
...(isMcpRuntime && wizard.config.selectedAgents.length > 0
199255
? [{ label: 'Agents', value: wizard.config.selectedAgents.join(', ') }]
200256
: []),
201257
...(!isMcpRuntime && wizard.config.gateway ? [{ label: 'Gateway', value: wizard.config.gateway }] : []),
202-
{ label: 'Host', value: wizard.config.host },
203-
{ label: 'Source', value: wizard.config.sourcePath },
258+
...(!isMcpRuntime && !wizard.config.gateway
259+
? [{ label: 'Gateway', value: '(none - assign later)' }]
260+
: []),
261+
...(wizard.config.source === 'create-new' ? [{ label: 'Host', value: wizard.config.host }] : []),
262+
...(wizard.config.source === 'create-new' ? [{ label: 'Source', value: wizard.config.sourcePath }] : []),
204263
]}
205264
/>
206265
)}

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,16 @@ export type ComputeHost = 'Lambda' | 'AgentCoreRuntime';
4747
* - host: Select compute host (only if behind-gateway)
4848
* - confirm: Review and confirm
4949
*/
50-
export type AddGatewayTargetStep = 'name' | 'language' | 'exposure' | 'agents' | 'gateway' | 'host' | 'confirm';
50+
export type AddGatewayTargetStep =
51+
| 'name'
52+
| 'source'
53+
| 'endpoint'
54+
| 'language'
55+
| 'exposure'
56+
| 'agents'
57+
| 'gateway'
58+
| 'host'
59+
| 'confirm';
5160

5261
export type TargetLanguage = 'Python' | 'TypeScript' | 'Other';
5362

@@ -57,6 +66,10 @@ export interface AddGatewayTargetConfig {
5766
sourcePath: string;
5867
language: TargetLanguage;
5968
exposure: ExposureMode;
69+
/** Source type for external endpoints */
70+
source?: 'existing-endpoint' | 'create-new';
71+
/** External endpoint URL */
72+
endpoint?: string;
6073
/** Gateway name (only when exposure = behind-gateway) */
6174
gateway?: string;
6275
/** Compute host (AgentCoreRuntime for mcp-runtime, Lambda or AgentCoreRuntime for behind-gateway) */
@@ -75,6 +88,8 @@ export interface AddGatewayTargetConfig {
7588

7689
export const MCP_TOOL_STEP_LABELS: Record<AddGatewayTargetStep, string> = {
7790
name: 'Name',
91+
source: 'Source',
92+
endpoint: 'Endpoint',
7893
language: 'Language',
7994
exposure: 'Exposure',
8095
agents: 'Agents',
@@ -93,6 +108,13 @@ export const AUTHORIZER_TYPE_OPTIONS = [
93108
{ id: 'NONE', title: 'None', description: 'No authorization required — gateway is publicly accessible' },
94109
] as const;
95110

111+
export const SKIP_FOR_NOW = 'skip-for-now' as const;
112+
113+
export const SOURCE_OPTIONS = [
114+
{ id: 'existing-endpoint', title: 'Existing endpoint', description: 'Connect to an existing MCP server' },
115+
{ id: 'create-new', title: 'Create new', description: 'Scaffold a new MCP server' },
116+
] as const;
117+
96118
export const TARGET_LANGUAGE_OPTIONS = [
97119
{ id: 'Python', title: 'Python', description: 'FastMCP Python server' },
98120
{ id: 'TypeScript', title: 'TypeScript', description: 'MCP TypeScript server' },

0 commit comments

Comments
 (0)