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
5 changes: 5 additions & 0 deletions src/cli/commands/add/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export interface ValidatedAddGatewayOptions {
export interface ValidatedAddGatewayTargetOptions {
name: string;
description?: string;
type?: string;
source?: 'existing-endpoint' | 'create-new';
endpoint?: string;
language: 'Python' | 'TypeScript' | 'Other';
exposure: 'mcp-runtime' | 'behind-gateway';
agents?: string;
Expand Down Expand Up @@ -304,6 +307,8 @@ function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): Ad
sourcePath,
language: options.language,
exposure: options.exposure,
source: options.source,
endpoint: options.endpoint,
host: options.exposure === 'mcp-runtime' ? 'AgentCoreRuntime' : options.host!,
toolDefinition: {
name: options.name,
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/add/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ export function registerAdd(program: Command) {
.description('Add a gateway target to the project')
.option('--name <name>', 'Tool name')
.option('--description <desc>', 'Tool description')
.option('--type <type>', 'Target type: mcpServer or lambda')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this --type option is added but never used. validate.ts validates it, but nothing in the flow reads options.type to change behavior

.option('--source <source>', 'Source: existing-endpoint or create-new')
.option('--endpoint <url>', 'MCP server endpoint URL')
.option('--language <lang>', 'Language: Python or TypeScript')
.option('--exposure <mode>', 'Exposure mode: mcp-runtime or behind-gateway')
.option('--agents <names>', 'Comma-separated agent names (for mcp-runtime)')
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/add/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export interface AddGatewayResult {
export interface AddGatewayTargetOptions {
name?: string;
description?: string;
type?: string;
source?: string;
endpoint?: string;
language?: 'Python' | 'TypeScript' | 'Other';
exposure?: 'mcp-runtime' | 'behind-gateway';
agents?: string;
Expand Down
30 changes: 30 additions & 0 deletions src/cli/commands/add/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,36 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO
return { valid: false, error: '--name is required' };
}

if (options.type && options.type !== 'mcpServer' && options.type !== 'lambda') {
return { valid: false, error: 'Invalid type. Valid options: mcpServer, lambda' };
}

if (options.source && options.source !== 'existing-endpoint' && options.source !== 'create-new') {
return { valid: false, error: 'Invalid source. Valid options: existing-endpoint, create-new' };
}

if (options.source === 'existing-endpoint') {
if (!options.endpoint) {
return { valid: false, error: '--endpoint is required when source is existing-endpoint' };
}
Comment on lines +200 to +203
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When source === 'existing-endpoint', this returns { valid: true } immediately but ValidatedAddGatewayTargetOptions still requires language, exposure, etc. In non-interactive mode, downstream code that reads those fields will get undefined.

We cna either return a distinct validated type for external endpoints, or populate sensible defaults for the skipped fields before returning.


try {
const url = new URL(options.endpoint);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return { valid: false, error: 'Endpoint must use http:// or https:// protocol' };
}
} catch {
return { valid: false, error: 'Endpoint must be a valid URL (e.g. https://example.com/mcp)' };
}

// Populate defaults for fields skipped by external endpoint flow
options.language ??= 'Other';
options.exposure ??= 'behind-gateway';
options.gateway ??= undefined;

return { valid: true };
}

if (!options.language) {
return { valid: false, error: '--language is required' };
}
Expand Down
58 changes: 57 additions & 1 deletion src/cli/operations/mcp/create-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import type {
import { AgentCoreCliMcpDefsSchema, ToolDefinitionSchema } from '../../../schema';
import { getTemplateToolDefinitions, renderGatewayTargetTemplate } from '../../templates/GatewayTargetRenderer';
import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../tui/screens/mcp/types';
import { DEFAULT_HANDLER, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION } from '../../tui/screens/mcp/types';
import {
DEFAULT_HANDLER,
DEFAULT_NODE_VERSION,
DEFAULT_PYTHON_VERSION,
SKIP_FOR_NOW,
} from '../../tui/screens/mcp/types';
import { existsSync } from 'fs';
import { mkdir, readFile, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
Expand Down Expand Up @@ -198,6 +203,57 @@ async function validateCredentialName(credentialName: string): Promise<void> {
}
}

/**
* Create an external MCP server target (existing endpoint).
*/
export async function createExternalGatewayTarget(config: AddGatewayTargetConfig): Promise<CreateToolResult> {
if (!config.endpoint) {
throw new Error('Endpoint URL is required for external MCP server targets.');
}

const configIO = new ConfigIO();
const mcpSpec: AgentCoreMcpSpec = configIO.configExists('mcp')
? await configIO.readMcpSpec()
: { agentCoreGateways: [], unassignedTargets: [] };

const target: AgentCoreGatewayTarget = {
name: config.name,
targetType: 'mcpServer',
endpoint: config.endpoint,
toolDefinitions: [config.toolDefinition],
...(config.outboundAuth && { outboundAuth: config.outboundAuth }),
};

if (config.gateway && config.gateway !== SKIP_FOR_NOW) {
// Assign to specific gateway
const gateway = mcpSpec.agentCoreGateways.find(g => g.name === config.gateway);
if (!gateway) {
throw new Error(`Gateway "${config.gateway}" not found.`);
}

// Check for duplicate target name
if (gateway.targets.some(t => t.name === config.name)) {
throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`);
}

gateway.targets.push(target);
} else {
// Add to unassigned targets
mcpSpec.unassignedTargets ??= [];

// Check for duplicate target name in unassigned targets
if (mcpSpec.unassignedTargets.some((t: AgentCoreGatewayTarget) => t.name === config.name)) {
throw new Error(`Unassigned target "${config.name}" already exists.`);
}

mcpSpec.unassignedTargets.push(target);
}

await configIO.writeMcpSpec(mcpSpec);

return { mcpDefsPath: '', toolName: config.name, projectPath: '' };
}

/**
* Create an MCP tool (MCP runtime or behind gateway).
*/
Expand Down
28 changes: 20 additions & 8 deletions src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createExternalGatewayTarget } from '../../../operations/mcp/create-mcp';
import { ErrorPrompt, Panel, Screen, TextInput, WizardSelect } from '../../components';
import type { SelectableItem } from '../../components';
import { HELP_TEXT } from '../../constants';
Expand Down Expand Up @@ -114,14 +115,25 @@ export function AddGatewayTargetFlow({
loading: true,
loadingMessage: 'Creating MCP tool...',
});
void createTool(config).then(result => {
if (result.ok) {
const { toolName, projectPath } = result.result;
setFlow({ name: 'create-success', toolName, projectPath });
return;
}
setFlow({ name: 'error', message: result.error });
});

if (config.source === 'existing-endpoint') {
void createExternalGatewayTarget(config)
.then((result: { toolName: string; projectPath: string }) => {
setFlow({ name: 'create-success', toolName: result.toolName, projectPath: result.projectPath });
})
.catch((err: unknown) => {
setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The parameter is typed as Error, but .catch() receives unknown at runtime. The instanceof check is actually correct, the type annotation should be unknown to be honest about it:

.catch((err: unknown) => {

});
} else {
void createTool(config).then(result => {
if (result.ok) {
const { toolName, projectPath } = result.result;
setFlow({ name: 'create-success', toolName, projectPath });
return;
}
setFlow({ name: 'error', message: result.error });
});
}
},
[createTool]
);
Expand Down
83 changes: 71 additions & 12 deletions src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ import { HELP_TEXT } from '../../constants';
import { useListNavigation, useMultiSelectNavigation } from '../../hooks';
import { generateUniqueName } from '../../utils';
import type { AddGatewayTargetConfig, ComputeHost, ExposureMode, TargetLanguage } from './types';
import { COMPUTE_HOST_OPTIONS, EXPOSURE_MODE_OPTIONS, MCP_TOOL_STEP_LABELS, TARGET_LANGUAGE_OPTIONS } from './types';
import {
COMPUTE_HOST_OPTIONS,
EXPOSURE_MODE_OPTIONS,
MCP_TOOL_STEP_LABELS,
SKIP_FOR_NOW,
SOURCE_OPTIONS,
TARGET_LANGUAGE_OPTIONS,
} from './types';
import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard';
import { Box, Text } from 'ink';
import React, { useMemo } from 'react';
Expand All @@ -35,6 +42,11 @@ export function AddGatewayTargetScreen({
}: AddGatewayTargetScreenProps) {
const wizard = useAddGatewayTargetWizard(existingGateways, existingAgents);

const sourceItems: SelectableItem[] = useMemo(
() => SOURCE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
[]
);

const languageItems: SelectableItem[] = useMemo(
() => TARGET_LANGUAGE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })),
[]
Expand All @@ -52,7 +64,10 @@ export function AddGatewayTargetScreen({
);

const gatewayItems: SelectableItem[] = useMemo(
() => existingGateways.map(g => ({ id: g, title: g })),
() => [
...existingGateways.map(g => ({ id: g, title: g })),
{ id: SKIP_FOR_NOW, title: 'Skip for now', description: 'Create unassigned target' },
],
[existingGateways]
);

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

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

const isSourceStep = wizard.step === 'source';
const isLanguageStep = wizard.step === 'language';
const isExposureStep = wizard.step === 'exposure';
const isAgentsStep = wizard.step === 'agents';
const isGatewayStep = wizard.step === 'gateway';
const isHostStep = wizard.step === 'host';
const isTextStep = wizard.step === 'name';
const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint';
const isConfirmStep = wizard.step === 'confirm';
const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0;
const noAgentsAvailable = isAgentsStep && existingAgents.length === 0;

const sourceNav = useListNavigation({
items: sourceItems,
onSelect: item => wizard.setSource(item.id as 'existing-endpoint' | 'create-new'),
onExit: () => wizard.goBack(),
isActive: isSourceStep,
});

const languageNav = useListNavigation({
items: languageItems,
onSelect: item => wizard.setLanguage(item.id as TargetLanguage),
Expand Down Expand Up @@ -132,6 +155,15 @@ export function AddGatewayTargetScreen({
return (
<Screen title="Add MCP Tool" onExit={onExit} helpText={helpText} headerContent={headerContent}>
<Panel>
{isSourceStep && (
<WizardSelect
title="Select source"
description="How would you like to create this MCP tool?"
items={sourceItems}
selectedIndex={sourceNav.selectedIndex}
/>
)}

{isLanguageStep && (
<WizardSelect title="Select language" items={languageItems} selectedIndex={languageNav.selectedIndex} />
)}
Expand Down Expand Up @@ -180,27 +212,54 @@ export function AddGatewayTargetScreen({
{isTextStep && (
<TextInput
key={wizard.step}
prompt={MCP_TOOL_STEP_LABELS[wizard.step]}
initialValue={generateUniqueName('mytool', existingToolNames)}
onSubmit={wizard.setName}
prompt={wizard.step === 'endpoint' ? 'MCP server endpoint URL' : MCP_TOOL_STEP_LABELS[wizard.step]}
initialValue={wizard.step === 'endpoint' ? undefined : generateUniqueName('mytool', existingToolNames)}
placeholder={wizard.step === 'endpoint' ? 'https://example.com/mcp' : undefined}
onSubmit={wizard.step === 'endpoint' ? wizard.setEndpoint : wizard.setName}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing endpoint validation in the TUI path which means users can submit any string. the CLI path validates the URL in validate.ts, but the interactive wizard does not. we can add a customValidation that mirrors the new URL() + protocol check.

onCancel={() => (wizard.currentIndex === 0 ? onExit() : wizard.goBack())}
schema={ToolNameSchema}
customValidation={value => !existingToolNames.includes(value) || 'Tool name already exists'}
schema={wizard.step === 'name' ? ToolNameSchema : undefined}
customValidation={
wizard.step === 'name'
? value => !existingToolNames.includes(value) || 'Tool name already exists'
: wizard.step === 'endpoint'
? value => {
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Endpoint must use http:// or https:// protocol';
}
return true;
} catch {
return 'Must be a valid URL (e.g. https://example.com/mcp)';
}
}
: undefined
}
/>
)}

{isConfirmStep && (
<ConfirmReview
fields={[
{ label: 'Name', value: wizard.config.name },
{ label: 'Language', value: wizard.config.language },
{ label: 'Exposure', value: isMcpRuntime ? 'MCP Runtime' : 'Behind Gateway' },
{
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.source === 'create-new'
? [{ label: 'Exposure', value: isMcpRuntime ? 'MCP Runtime' : 'Behind Gateway' }]
: []),
...(isMcpRuntime && wizard.config.selectedAgents.length > 0
? [{ label: 'Agents', value: wizard.config.selectedAgents.join(', ') }]
: []),
...(!isMcpRuntime && wizard.config.gateway ? [{ label: 'Gateway', value: wizard.config.gateway }] : []),
{ label: 'Host', value: wizard.config.host },
{ label: 'Source', value: wizard.config.sourcePath },
...(!isMcpRuntime && !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 }] : []),
]}
/>
)}
Expand Down
24 changes: 23 additions & 1 deletion src/cli/tui/screens/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@ export type ComputeHost = 'Lambda' | 'AgentCoreRuntime';
* - host: Select compute host (only if behind-gateway)
* - confirm: Review and confirm
*/
export type AddGatewayTargetStep = 'name' | 'language' | 'exposure' | 'agents' | 'gateway' | 'host' | 'confirm';
export type AddGatewayTargetStep =
| 'name'
| 'source'
| 'endpoint'
| 'language'
| 'exposure'
| 'agents'
| 'gateway'
| 'host'
| 'confirm';

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

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

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

export const SKIP_FOR_NOW = 'skip-for-now' as const;

export const SOURCE_OPTIONS = [
{ id: 'existing-endpoint', title: 'Existing endpoint', description: 'Connect to an existing MCP server' },
{ id: 'create-new', title: 'Create new', description: 'Scaffold a new MCP server' },
] as const;

export const TARGET_LANGUAGE_OPTIONS = [
{ id: 'Python', title: 'Python', description: 'FastMCP Python server' },
{ id: 'TypeScript', title: 'TypeScript', description: 'MCP TypeScript server' },
Expand Down
Loading
Loading