From 63354d8e32c0d00f5c71a217b68548a719e4d506 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 4 Mar 2026 21:16:03 -0500 Subject: [PATCH 1/7] feat: add target type picker to gateway target wizard - Add targetType field to AddGatewayTargetConfig and 'target-type' wizard step - Convert wizard from static to dynamic steps (useMemo with config.targetType dep) - Fix goBack() to use memoized steps instead of stale getSteps() call - Add TARGET_TYPE_OPTIONS with mcpServer as initial option - Add WizardSelect UI for target type selection in AddGatewayTargetScreen - Route flow on config.targetType instead of config.source - Remove source field, SOURCE_OPTIONS, and 'source' step (dead code) --- .../tui/screens/mcp/AddGatewayTargetFlow.tsx | 2 +- .../screens/mcp/AddGatewayTargetScreen.tsx | 32 ++++++++++++++- src/cli/tui/screens/mcp/types.ts | 21 ++++++---- .../screens/mcp/useAddGatewayTargetWizard.ts | 39 ++++++++++++------- 4 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx index 58c18b466..f35aff5a3 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -63,7 +63,7 @@ export function AddGatewayTargetFlow({ loadingMessage: 'Creating gateway target...', }); - if (config.source === 'existing-endpoint') { + if (config.targetType === 'mcpServer') { void gatewayTargetPrimitive .createExternalGatewayTarget(config) .then((result: { toolName: string; projectPath: string }) => { diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 169b96332..7c891e5a3 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -1,3 +1,4 @@ +import type { GatewayTargetType } from '../../../../schema'; import { ToolNameSchema } from '../../../../schema'; import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; @@ -5,7 +6,7 @@ import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; import type { AddGatewayTargetConfig } from './types'; -import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS } from './types'; +import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS, TARGET_TYPE_OPTIONS } from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; import { Box, Text } from 'ink'; import React, { useMemo, useState } from 'react'; @@ -51,12 +52,25 @@ export function AddGatewayTargetScreen({ return items; }, [existingOAuthCredentialNames]); + const targetTypeItems: SelectableItem[] = useMemo( + () => TARGET_TYPE_OPTIONS.map(o => ({ id: o.id, title: o.title, description: o.description })), + [] + ); + const isGatewayStep = wizard.step === 'gateway'; const isOutboundAuthStep = wizard.step === 'outbound-auth'; + const isTargetTypeStep = wizard.step === 'target-type'; const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint'; const isConfirmStep = wizard.step === 'confirm'; const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; + const targetTypeNav = useListNavigation({ + items: targetTypeItems, + onSelect: item => wizard.setTargetType(item.id as GatewayTargetType), + onExit: () => wizard.goBack(), + isActive: isTargetTypeStep, + }); + const gatewayNav = useListNavigation({ items: gatewayItems, onSelect: item => wizard.setGateway(item.id), @@ -117,6 +131,15 @@ export function AddGatewayTargetScreen({ return ( + {isTargetTypeStep && ( + + )} + {isGatewayStep && !noGatewaysAvailable && ( o.id === wizard.config.targetType)?.title ?? + wizard.config.targetType ?? + '', + }, ...(wizard.config.endpoint ? [{ label: 'Endpoint', value: wizard.config.endpoint }] : []), { label: 'Gateway', value: wizard.config.gateway ?? '' }, ...(wizard.config.outboundAuth diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index f24aeed54..2f176b800 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -1,4 +1,10 @@ -import type { GatewayAuthorizerType, NodeRuntime, PythonRuntime, ToolDefinition } from '../../../../schema'; +import type { + GatewayAuthorizerType, + GatewayTargetType, + NodeRuntime, + PythonRuntime, + ToolDefinition, +} from '../../../../schema'; // ───────────────────────────────────────────────────────────────────────────── // Gateway Flow Types @@ -48,7 +54,7 @@ export type ComputeHost = 'Lambda' | 'AgentCoreRuntime'; */ export type AddGatewayTargetStep = | 'name' - | 'source' + | 'target-type' | 'endpoint' | 'language' | 'gateway' @@ -63,8 +69,8 @@ export interface AddGatewayTargetConfig { description: string; sourcePath: string; language: TargetLanguage; - /** Source type for external endpoints */ - source?: 'existing-endpoint' | 'create-new'; + /** Target type selected by user */ + targetType?: GatewayTargetType; /** External endpoint URL */ endpoint?: string; /** Gateway name */ @@ -83,7 +89,7 @@ export interface AddGatewayTargetConfig { export const MCP_TOOL_STEP_LABELS: Record = { name: 'Name', - source: 'Source', + 'target-type': 'Target Type', endpoint: 'Endpoint', language: 'Language', gateway: 'Gateway', @@ -104,9 +110,8 @@ export const AUTHORIZER_TYPE_OPTIONS = [ 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' }, +export const TARGET_TYPE_OPTIONS = [ + { id: 'mcpServer', title: 'MCP Server endpoint', description: 'Connect to an existing MCP-compatible server' }, ] as const; export const TARGET_LANGUAGE_OPTIONS = [ diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index d29daaf68..3295a2b1b 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -1,16 +1,8 @@ import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; -import type { ToolDefinition } from '../../../../schema'; +import type { GatewayTargetType, ToolDefinition } from '../../../../schema'; import type { AddGatewayTargetConfig, AddGatewayTargetStep } from './types'; import { useCallback, useMemo, useState } from 'react'; -/** - * Steps for adding a gateway target (existing endpoint only). - * name → endpoint → gateway → outbound-auth → confirm - */ -function getSteps(): AddGatewayTargetStep[] { - return ['name', 'endpoint', 'gateway', 'outbound-auth', 'confirm']; -} - function deriveToolDefinition(name: string): ToolDefinition { return { name, @@ -24,7 +16,6 @@ function getDefaultConfig(): AddGatewayTargetConfig { name: '', description: '', sourcePath: '', - source: 'existing-endpoint', language: 'Python', host: 'Lambda', toolDefinition: deriveToolDefinition(''), @@ -35,15 +26,27 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); - const steps = useMemo(() => getSteps(), []); + // Dynamic steps — recomputes when targetType changes + const steps = useMemo(() => { + const baseSteps: AddGatewayTargetStep[] = ['name', 'target-type']; + if (config.targetType) { + switch (config.targetType) { + case 'mcpServer': + default: + baseSteps.push('endpoint', 'gateway', 'outbound-auth'); + break; + } + baseSteps.push('confirm'); + } + return baseSteps; + }, [config.targetType]); + const currentIndex = steps.indexOf(step); const goBack = useCallback(() => { - const currentSteps = getSteps(); - const idx = currentSteps.indexOf(step); - const prevStep = currentSteps[idx - 1]; + const prevStep = steps[currentIndex - 1]; if (prevStep) setStep(prevStep); - }, [step]); + }, [currentIndex, steps]); const setName = useCallback((name: string) => { setConfig(c => ({ @@ -53,6 +56,11 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { sourcePath: `${APP_DIR}/${MCP_APP_SUBDIR}/${name}`, toolDefinition: deriveToolDefinition(name), })); + setStep('target-type'); + }, []); + + const setTargetType = useCallback((targetType: GatewayTargetType) => { + setConfig(c => ({ ...c, targetType })); setStep('endpoint'); }, []); @@ -93,6 +101,7 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { existingGateways, goBack, setName, + setTargetType, setEndpoint, setGateway, setOutboundAuth, From 2b599e69f12a0037f96f69fafd69edca0a7a5715 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 4 Mar 2026 21:16:45 -0500 Subject: [PATCH 2/7] feat: make --type required and remove --source for gateway-target CLI - Make --type required with kebab-case input (mcp-server -> mcpServer) - Remove --source option entirely (was dead code, only existing-endpoint worked) - Wire options.type through buildGatewayTargetConfig to config.targetType - Route handleAddGatewayTarget on config.targetType instead of config.source - Use config.targetType in createExternalGatewayTarget instead of hardcoding --- src/cli/commands/add/command.tsx | 350 +++++++++++++++++-- src/cli/commands/add/types.ts | 1 - src/cli/commands/add/validate.ts | 22 +- src/cli/primitives/GatewayTargetPrimitive.ts | 11 +- 4 files changed, 343 insertions(+), 41 deletions(-) diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index c11df2cce..b40487a95 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -1,40 +1,344 @@ import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; import { AddFlow } from '../../tui/screens/add/AddFlow'; +import { + handleAddAgent, + handleAddGateway, + handleAddGatewayTarget, + handleAddIdentity, + handleAddMemory, +} from './actions'; +import type { + AddAgentOptions, + AddGatewayOptions, + AddGatewayTargetOptions, + AddIdentityOptions, + AddMemoryOptions, +} from './types'; +import { + validateAddAgentOptions, + validateAddGatewayOptions, + validateAddGatewayTargetOptions, + validateAddIdentityOptions, + validateAddMemoryOptions, +} from './validate'; import type { Command } from '@commander-js/extra-typings'; import { render } from 'ink'; import React from 'react'; -export function registerAdd(program: Command): Command { +async function handleAddAgentCLI(options: AddAgentOptions): Promise { + const validation = validateAddAgentOptions(options); + if (!validation.valid) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: validation.error })); + } else { + console.error(validation.error); + } + process.exit(1); + } + + const result = await handleAddAgent({ + name: options.name!, + type: options.type! ?? 'create', + buildType: (options.build as 'CodeZip' | 'Container') ?? 'CodeZip', + language: options.language!, + framework: options.framework!, + modelProvider: options.modelProvider!, + apiKey: options.apiKey, + memory: options.memory, + codeLocation: options.codeLocation, + entrypoint: options.entrypoint, + }); + + if (options.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added agent '${result.agentName}'`); + if (result.agentPath) { + console.log(`Agent code: ${result.agentPath}`); + } + } else { + console.error(result.error); + } + + process.exit(result.success ? 0 : 1); +} + +async function handleAddGatewayCLI(options: AddGatewayOptions): Promise { + const validation = validateAddGatewayOptions(options); + if (!validation.valid) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: validation.error })); + } else { + console.error(validation.error); + } + process.exit(1); + } + + const result = await handleAddGateway({ + name: options.name!, + description: options.description, + authorizerType: options.authorizerType ?? 'NONE', + discoveryUrl: options.discoveryUrl, + allowedAudience: options.allowedAudience, + allowedClients: options.allowedClients, + allowedScopes: options.allowedScopes, + agentClientId: options.agentClientId, + agentClientSecret: options.agentClientSecret, + agents: options.agents, + }); + + if (options.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added gateway '${result.gatewayName}'`); + } else { + console.error(result.error); + } + + process.exit(result.success ? 0 : 1); +} + +async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Promise { + const validation = await validateAddGatewayTargetOptions(options); + if (!validation.valid) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: validation.error })); + } else { + console.error(validation.error); + } + process.exit(1); + } + + // Map CLI flag values to internal types + const outboundAuthMap: Record = { + oauth: 'OAUTH', + 'api-key': 'API_KEY', + none: 'NONE', + }; + + const result = await handleAddGatewayTarget({ + name: options.name!, + description: options.description, + type: options.type!, + endpoint: options.endpoint, + language: options.language! as 'Python' | 'TypeScript', + gateway: options.gateway, + host: options.host, + outboundAuthType: options.outboundAuthType ? outboundAuthMap[options.outboundAuthType.toLowerCase()] : undefined, + credentialName: options.credentialName, + oauthClientId: options.oauthClientId, + oauthClientSecret: options.oauthClientSecret, + oauthDiscoveryUrl: options.oauthDiscoveryUrl, + oauthScopes: options.oauthScopes, + }); + + if (options.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added gateway target '${result.toolName}'`); + if (result.sourcePath) { + console.log(`Tool code: ${result.sourcePath}`); + } + } else { + console.error(result.error); + } + + process.exit(result.success ? 0 : 1); +} + +// v2: Memory is a top-level resource (no owner/user) +async function handleAddMemoryCLI(options: AddMemoryOptions): Promise { + const validation = validateAddMemoryOptions(options); + if (!validation.valid) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: validation.error })); + } else { + console.error(validation.error); + } + process.exit(1); + } + + const result = await handleAddMemory({ + name: options.name!, + strategies: options.strategies, + expiry: options.expiry, + }); + + if (options.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added memory '${result.memoryName}'`); + } else { + console.error(result.error); + } + + process.exit(result.success ? 0 : 1); +} + +// v2: Identity/Credential is a top-level resource (no owner/user) +async function handleAddIdentityCLI(options: AddIdentityOptions): Promise { + const validation = validateAddIdentityOptions(options); + if (!validation.valid) { + if (options.json) { + console.log(JSON.stringify({ success: false, error: validation.error })); + } else { + console.error(validation.error); + } + process.exit(1); + } + + const identityType = options.type ?? 'api-key'; + const result = + identityType === 'oauth' + ? await handleAddIdentity({ + type: 'oauth', + name: options.name!, + discoveryUrl: options.discoveryUrl!, + clientId: options.clientId!, + clientSecret: options.clientSecret!, + scopes: options.scopes, + }) + : await handleAddIdentity({ + type: 'api-key', + name: options.name!, + apiKey: options.apiKey!, + }); + + if (options.json) { + console.log(JSON.stringify(result)); + } else if (result.success) { + console.log(`Added credential '${result.credentialName}'`); + } else { + console.error(result.error); + } + + process.exit(result.success ? 0 : 1); +} + +export function registerAdd(program: Command) { const addCmd = program .command('add') .description(COMMAND_DESCRIPTIONS.add) + // Catch-all argument for invalid subcommands - Commander matches subcommands first + .argument('[subcommand]') + .action((subcommand: string | undefined, _options, cmd) => { + if (subcommand) { + console.error(`error: '${subcommand}' is not a valid subcommand.`); + cmd.outputHelp(); + process.exit(1); + } + + requireProject(); + + const { clear, unmount } = render( + { + clear(); + unmount(); + }} + /> + ); + }) .showHelpAfterError() .showSuggestionAfterError(); - // Catch-all argument for invalid subcommands - Commander matches subcommands first - addCmd.argument('[subcommand]').action((subcommand: string | undefined, _options, cmd) => { - if (subcommand) { - console.error(`error: '${subcommand}' is not a valid subcommand.`); - cmd.outputHelp(); - process.exit(1); - } + // Subcommand: add agent + addCmd + .command('agent') + .description('Add an agent to the project') + .option('--name ', 'Agent name (start with letter, alphanumeric only, max 64 chars) [non-interactive]') + .option('--type ', 'Agent type: create or byo [non-interactive]', 'create') + .option('--build ', 'Build type: CodeZip or Container (default: CodeZip) [non-interactive]') + .option('--language ', 'Language: Python (create), or Python/TypeScript/Other (BYO) [non-interactive]') + .option( + '--framework ', + 'Framework: Strands, LangChain_LangGraph, CrewAI, GoogleADK, OpenAIAgents [non-interactive]' + ) + .option('--model-provider ', 'Model provider: Bedrock, Anthropic, OpenAI, Gemini [non-interactive]') + .option('--api-key ', 'API key for non-Bedrock providers [non-interactive]') + .option('--memory ', 'Memory: none, shortTerm, longAndShortTerm (create path only) [non-interactive]') + .option('--code-location ', 'Path to existing code (BYO path only) [non-interactive]') + .option('--entrypoint ', 'Entry file relative to code-location (BYO, default: main.py) [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action(async options => { + requireProject(); + await handleAddAgentCLI(options as AddAgentOptions); + }); - requireProject(); - - const { clear, unmount } = render( - { - clear(); - unmount(); - }} - /> - ); - }); + // Subcommand: add gateway + addCmd + .command('gateway') + .description('Add a gateway to the project') + .option('--name ', 'Gateway name') + .option('--description ', 'Gateway description') + .option('--authorizer-type ', 'Authorizer type: NONE or CUSTOM_JWT', 'NONE') + .option('--discovery-url ', 'OIDC discovery URL (required for CUSTOM_JWT)') + .option('--allowed-audience ', 'Comma-separated allowed audience values (required for CUSTOM_JWT)') + .option('--allowed-clients ', 'Comma-separated allowed client IDs (required for CUSTOM_JWT)') + .option('--allowed-scopes ', 'Comma-separated allowed scopes (optional for CUSTOM_JWT)') + .option('--agent-client-id ', 'Agent OAuth client ID for Bearer token auth (CUSTOM_JWT)') + .option('--agent-client-secret ', 'Agent OAuth client secret (CUSTOM_JWT)') + .option('--json', 'Output as JSON') + .action(async options => { + requireProject(); + await handleAddGatewayCLI(options as AddGatewayOptions); + }); + + // Subcommand: add gateway-target + addCmd + .command('gateway-target') + .description('Add a gateway target to the project') + .option('--name ', 'Tool name') + .option('--description ', 'Tool description') + .option('--type ', 'Target type (required): mcp-server') + .option('--endpoint ', 'MCP server endpoint URL') + .option('--language ', 'Language: Python or TypeScript') + .option('--gateway ', 'Gateway name') + .option('--host ', 'Compute host: Lambda or AgentCoreRuntime') + .option('--outbound-auth ', 'Outbound auth type: oauth, api-key, or none') + .option('--credential-name ', 'Existing credential name for outbound auth') + .option('--oauth-client-id ', 'OAuth client ID (creates credential inline)') + .option('--oauth-client-secret ', 'OAuth client secret (creates credential inline)') + .option('--oauth-discovery-url ', 'OAuth discovery URL (creates credential inline)') + .option('--oauth-scopes ', 'OAuth scopes, comma-separated') + .option('--json', 'Output as JSON') + .action(async options => { + requireProject(); + await handleAddGatewayTargetCLI(options as AddGatewayTargetOptions); + }); - // Subcommands (agent, memory, identity, gateway, gateway-target) are registered - // via primitive.registerCommands() in cli.ts + // Subcommand: add memory (v2: top-level resource) + addCmd + .command('memory') + .description('Add a memory resource to the project') + .option('--name ', 'Memory name [non-interactive]') + .option( + '--strategies ', + 'Comma-separated strategies: SEMANTIC, SUMMARIZATION, USER_PREFERENCE [non-interactive]' + ) + .option('--expiry ', 'Event expiry duration in days (default: 30) [non-interactive]', parseInt) + .option('--json', 'Output as JSON [non-interactive]') + .action(async options => { + requireProject(); + await handleAddMemoryCLI(options as AddMemoryOptions); + }); - return addCmd; + // Subcommand: add identity (v2: top-level credential resource) + addCmd + .command('identity') + .description('Add a credential to the project') + .option('--name ', 'Credential name [non-interactive]') + .option('--type ', 'Credential type: api-key (default) or oauth') + .option('--api-key ', 'The API key value [non-interactive]') + .option('--discovery-url ', 'OAuth discovery URL') + .option('--client-id ', 'OAuth client ID') + .option('--client-secret ', 'OAuth client secret') + .option('--scopes ', 'OAuth scopes, comma-separated') + .option('--json', 'Output as JSON [non-interactive]') + .action(async options => { + requireProject(); + await handleAddIdentityCLI(options as AddIdentityOptions); + }); } diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 467571210..595bc10ed 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -49,7 +49,6 @@ export interface AddGatewayTargetOptions { name?: string; description?: string; type?: string; - source?: string; endpoint?: string; language?: 'Python' | 'TypeScript' | 'Other'; gateway?: string; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index d3cbf4f4a..3c02f38cd 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -219,13 +219,16 @@ 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.type) { + return { valid: false, error: '--type is required. Valid options: mcp-server' }; } - if (options.source && options.source !== 'existing-endpoint') { - return { valid: false, error: "Only 'existing-endpoint' source is currently supported" }; + const typeMap: Record = { 'mcp-server': 'mcpServer' }; + const mappedType = typeMap[options.type]; + if (!mappedType) { + return { valid: false, error: `Invalid type: ${options.type}. Valid options: mcp-server` }; } + options.type = mappedType; // Gateway is required — a gateway target must be attached to a gateway if (!options.gateway) { @@ -260,10 +263,7 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO }; } - // Default to existing-endpoint (only supported source for now) - options.source ??= 'existing-endpoint'; - - // Validate outbound auth configuration (applies to all source types) + // Validate outbound auth configuration if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { const hasInlineOAuth = !!(options.oauthClientId ?? options.oauthClientSecret ?? options.oauthDiscoveryUrl); @@ -309,12 +309,12 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } } - if (options.source === 'existing-endpoint') { + if (mappedType === 'mcpServer') { if (options.host) { - return { valid: false, error: '--host is not applicable for existing endpoint targets' }; + return { valid: false, error: '--host is not applicable for MCP server targets' }; } if (!options.endpoint) { - return { valid: false, error: '--endpoint is required when source is existing-endpoint' }; + return { valid: false, error: '--endpoint is required for mcp-server type' }; } try { diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index 292b8dfd7..27c432fcc 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -236,8 +236,7 @@ export class GatewayTargetPrimitive extends BasePrimitive', 'Target name') .option('--description ', 'Target description') - .option('--type ', 'Target type: mcpServer or lambda') - .option('--source ', 'Source: existing-endpoint or create-new') + .option('--type ', 'Target type (required): mcp-server') .option('--endpoint ', 'MCP server endpoint URL') .option('--language ', 'Language: Python, TypeScript, Other') .option('--gateway ', 'Gateway name') @@ -274,14 +273,15 @@ export class GatewayTargetPrimitive extends BasePrimitive Date: Wed, 4 Mar 2026 21:17:15 -0500 Subject: [PATCH 3/7] test: update gateway-target tests for target type picker - Replace source: 'existing-endpoint' with type: 'mcp-server' in all fixtures - Add type: 'mcpServer' to ValidatedAddGatewayTargetOptions test objects - Add tests for --type required validation and invalid type rejection - Replace SOURCE_OPTIONS test with TARGET_TYPE_OPTIONS test - Fix shared fixture mutation bug (spread before passing to validation) - Add --type mcp-server to CLI integration test args --- integ-tests/add-remove-gateway.test.ts | 2 + .../add/__tests__/add-gateway-target.test.ts | 4 +- .../commands/add/__tests__/validate.test.ts | 66 ++++--- .../mcp/__tests__/create-mcp.test.ts | 178 +++++++++++++++++- .../tui/screens/mcp/__tests__/types.test.ts | 11 +- 5 files changed, 224 insertions(+), 37 deletions(-) diff --git a/integ-tests/add-remove-gateway.test.ts b/integ-tests/add-remove-gateway.test.ts index 88d64cb0b..559b5d693 100644 --- a/integ-tests/add-remove-gateway.test.ts +++ b/integ-tests/add-remove-gateway.test.ts @@ -42,6 +42,8 @@ describe('integration: add and remove gateway with external MCP server', () => { 'gateway-target', '--name', targetName, + '--type', + 'mcp-server', '--endpoint', 'https://mcp.exa.ai/mcp', '--gateway', diff --git a/src/cli/commands/add/__tests__/add-gateway-target.test.ts b/src/cli/commands/add/__tests__/add-gateway-target.test.ts index ef8820bbe..9bafd869f 100644 --- a/src/cli/commands/add/__tests__/add-gateway-target.test.ts +++ b/src/cli/commands/add/__tests__/add-gateway-target.test.ts @@ -44,7 +44,7 @@ describe('add gateway-target command', () => { it('requires endpoint', async () => { const result = await runCLI( - ['add', 'gateway-target', '--name', 'noendpoint', '--gateway', gatewayName, '--json'], + ['add', 'gateway-target', '--name', 'noendpoint', '--type', 'mcp-server', '--gateway', gatewayName, '--json'], projectDir ); expect(result.exitCode).toBe(1); @@ -63,6 +63,8 @@ describe('add gateway-target command', () => { 'gateway-target', '--name', targetName, + '--type', + 'mcp-server', '--endpoint', 'https://mcp.exa.ai/mcp', '--gateway', diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 533d2f1e6..4bf5f4896 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -60,7 +60,7 @@ const validGatewayOptionsJwt: AddGatewayOptions = { const validGatewayTargetOptions: AddGatewayTargetOptions = { name: 'test-tool', - source: 'existing-endpoint', + type: 'mcp-server', endpoint: 'https://example.com/mcp', gateway: 'my-gateway', }; @@ -326,7 +326,7 @@ describe('validate', () => { it('returns error when no gateways exist', async () => { mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); - const result = await validateAddGatewayTargetOptions(validGatewayTargetOptions); + const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); expect(result.valid).toBe(false); expect(result.error).toContain('No gateways found'); expect(result.error).toContain('agentcore add gateway'); @@ -334,7 +334,7 @@ describe('validate', () => { it('returns error when specified gateway does not exist', async () => { mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [{ name: 'other-gateway' }] }); - const result = await validateAddGatewayTargetOptions(validGatewayTargetOptions); + const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); expect(result.valid).toBe(false); expect(result.error).toContain('Gateway "my-gateway" not found'); expect(result.error).toContain('other-gateway'); @@ -345,22 +345,21 @@ describe('validate', () => { const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); expect(result.valid).toBe(true); }); - // AC20: existing-endpoint source validation - it('rejects create-new source', async () => { + // AC20: type validation + it('returns error when --type is missing', async () => { const options: AddGatewayTargetOptions = { name: 'test-tool', - source: 'create-new' as any, gateway: 'my-gateway', }; const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(false); - expect(result.error).toBe("Only 'existing-endpoint' source is currently supported"); + expect(result.error).toContain('--type is required'); }); - it('passes for valid existing-endpoint with https', async () => { + it('accepts --type mcp-server', async () => { const options: AddGatewayTargetOptions = { name: 'test-tool', - source: 'existing-endpoint', + type: 'mcp-server', endpoint: 'https://example.com/mcp', gateway: 'my-gateway', }; @@ -369,10 +368,32 @@ describe('validate', () => { expect(options.language).toBe('Other'); }); - it('passes for valid existing-endpoint with http', async () => { + it('returns error for invalid --type', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + type: 'invalid', + gateway: 'my-gateway', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid type'); + }); + + it('passes for mcp-server with https endpoint', async () => { + const options: AddGatewayTargetOptions = { + name: 'test-tool', + type: 'mcp-server', + endpoint: 'https://example.com/mcp', + gateway: 'my-gateway', + }; + const result = await validateAddGatewayTargetOptions(options); + expect(result.valid).toBe(true); + }); + + it('passes for mcp-server with http endpoint', async () => { const options: AddGatewayTargetOptions = { name: 'test-tool', - source: 'existing-endpoint', + type: 'mcp-server', endpoint: 'http://localhost:3000/mcp', gateway: 'my-gateway', }; @@ -380,21 +401,21 @@ describe('validate', () => { expect(result.valid).toBe(true); }); - it('returns error for existing-endpoint without endpoint', async () => { + it('returns error for mcp-server without endpoint', async () => { const options: AddGatewayTargetOptions = { name: 'test-tool', - source: 'existing-endpoint', + type: 'mcp-server', gateway: 'my-gateway', }; const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(false); - expect(result.error).toBe('--endpoint is required when source is existing-endpoint'); + expect(result.error).toContain('--endpoint is required'); }); - it('returns error for existing-endpoint with non-http(s) URL', async () => { + it('returns error for mcp-server with non-http(s) URL', async () => { const options: AddGatewayTargetOptions = { name: 'test-tool', - source: 'existing-endpoint', + type: 'mcp-server', endpoint: 'ftp://example.com/mcp', gateway: 'my-gateway', }; @@ -403,10 +424,10 @@ describe('validate', () => { expect(result.error).toBe('Endpoint must use http:// or https:// protocol'); }); - it('returns error for existing-endpoint with invalid URL', async () => { + it('returns error for mcp-server with invalid URL', async () => { const options: AddGatewayTargetOptions = { name: 'test-tool', - source: 'existing-endpoint', + type: 'mcp-server', endpoint: 'not-a-url', gateway: 'my-gateway', }; @@ -423,6 +444,7 @@ describe('validate', () => { const options: AddGatewayTargetOptions = { name: 'test-tool', + type: 'mcp-server', endpoint: 'https://example.com/mcp', gateway: 'my-gateway', outboundAuthType: 'API_KEY', @@ -440,6 +462,7 @@ describe('validate', () => { const options: AddGatewayTargetOptions = { name: 'test-tool', + type: 'mcp-server', endpoint: 'https://example.com/mcp', gateway: 'my-gateway', outboundAuthType: 'API_KEY', @@ -457,6 +480,7 @@ describe('validate', () => { const options: AddGatewayTargetOptions = { name: 'test-tool', + type: 'mcp-server', endpoint: 'https://example.com/mcp', gateway: 'my-gateway', outboundAuthType: 'API_KEY', @@ -531,17 +555,17 @@ describe('validate', () => { expect(result.error).toBe('--oauth-discovery-url must be a valid URL'); }); - it('rejects --host with existing-endpoint', async () => { + it('rejects --host with mcp-server type', async () => { const options: AddGatewayTargetOptions = { name: 'test-tool', - source: 'existing-endpoint', + type: 'mcp-server', endpoint: 'https://example.com/mcp', host: 'Lambda', gateway: 'my-gateway', }; const result = await validateAddGatewayTargetOptions(options); expect(result.valid).toBe(false); - expect(result.error).toBe('--host is not applicable for existing endpoint targets'); + expect(result.error).toBe('--host is not applicable for MCP server targets'); }); }); diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts index eac2ffc79..a323db8d7 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -1,14 +1,176 @@ -import { GatewayPrimitive } from '../../../primitives/GatewayPrimitive.js'; -import { describe, expect, it } from 'vitest'; +import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../../tui/screens/mcp/types.js'; +import { createExternalGatewayTarget, createGatewayFromWizard, getUnassignedTargets } from '../create-mcp.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -const computeDefaultGatewayEnvVarName = (name: string) => GatewayPrimitive.computeDefaultGatewayEnvVarName(name); +const { mockReadMcpSpec, mockWriteMcpSpec, mockConfigExists, mockReadProjectSpec } = vi.hoisted(() => ({ + mockReadMcpSpec: vi.fn(), + mockWriteMcpSpec: vi.fn(), + mockConfigExists: vi.fn(), + mockReadProjectSpec: vi.fn(), +})); -describe('computeDefaultGatewayEnvVarName', () => { - it('converts simple name to env var', () => { - expect(computeDefaultGatewayEnvVarName('mygateway')).toBe('AGENTCORE_GATEWAY_MYGATEWAY_URL'); +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + configExists = mockConfigExists; + readMcpSpec = mockReadMcpSpec; + writeMcpSpec = mockWriteMcpSpec; + readProjectSpec = mockReadProjectSpec; + }, +})); + +function makeExternalConfig(overrides: Partial = {}): AddGatewayTargetConfig { + return { + name: 'test-target', + description: 'Test target', + sourcePath: '/tmp/test', + language: 'Other', + targetType: 'mcpServer', + endpoint: 'https://api.example.com', + gateway: 'test-gateway', + host: 'Lambda', + toolDefinition: { name: 'test-tool', description: 'Test tool' }, + ...overrides, + } as AddGatewayTargetConfig; +} + +describe('createExternalGatewayTarget', () => { + afterEach(() => vi.clearAllMocks()); + + it('creates target with endpoint and assigns to specified gateway', async () => { + const mockMcpSpec = { + agentCoreGateways: [{ name: 'test-gateway', targets: [] }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget(makeExternalConfig()); + + expect(mockWriteMcpSpec).toHaveBeenCalled(); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const gateway = written.agentCoreGateways[0]!; + expect(gateway.targets).toHaveLength(1); + expect(gateway.targets[0]!.name).toBe('test-target'); + expect(gateway.targets[0]!.endpoint).toBe('https://api.example.com'); + expect(gateway.targets[0]!.targetType).toBe('mcpServer'); + }); + + it('throws when gateway is not provided', async () => { + const mockMcpSpec = { agentCoreGateways: [{ name: 'test-gateway', targets: [] }] }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: undefined }))).rejects.toThrow( + 'Gateway is required' + ); + }); + + it('throws on duplicate target name in gateway', async () => { + const mockMcpSpec = { + agentCoreGateways: [{ name: 'test-gateway', targets: [{ name: 'test-target' }] }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig())).rejects.toThrow( + 'Target "test-target" already exists in gateway "test-gateway"' + ); + }); + + it('throws when gateway not found', async () => { + const mockMcpSpec = { agentCoreGateways: [] }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: 'nonexistent' }))).rejects.toThrow( + 'Gateway "nonexistent" not found' + ); + }); + + it('includes outboundAuth when configured', async () => { + const mockMcpSpec = { + agentCoreGateways: [{ name: 'test-gateway', targets: [] }], + }; + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue(mockMcpSpec); + + await createExternalGatewayTarget( + makeExternalConfig({ outboundAuth: { type: 'API_KEY', credentialName: 'my-cred' } }) + ); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const target = written.agentCoreGateways[0]!.targets[0]!; + expect(target.outboundAuth).toEqual({ type: 'API_KEY', credentialName: 'my-cred' }); }); +}); + +describe('getUnassignedTargets', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns unassigned targets from mcp spec', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [], + unassignedTargets: [{ name: 't1' }, { name: 't2' }], + }); + + const result = await getUnassignedTargets(); + expect(result).toHaveLength(2); + expect(result[0]!.name).toBe('t1'); + }); + + it('returns empty array when no mcp config exists', async () => { + mockConfigExists.mockReturnValue(false); + expect(await getUnassignedTargets()).toEqual([]); + }); + + it('returns empty array when unassignedTargets field is missing', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); + expect(await getUnassignedTargets()).toEqual([]); + }); +}); + +describe('createGatewayFromWizard with selectedTargets', () => { + afterEach(() => vi.clearAllMocks()); + + function makeGatewayConfig(overrides: Partial = {}): AddGatewayConfig { + return { + name: 'new-gateway', + authorizerType: 'AWS_IAM', + ...overrides, + } as AddGatewayConfig; + } + + it('moves selected targets to new gateway and removes from unassigned', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ + agentCoreGateways: [], + unassignedTargets: [ + { name: 'target-a', targetType: 'mcpServer' }, + { name: 'target-b', targetType: 'mcpServer' }, + { name: 'target-c', targetType: 'mcpServer' }, + ], + }); + + await createGatewayFromWizard(makeGatewayConfig({ selectedTargets: ['target-a', 'target-c'] })); + + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const gateway = written.agentCoreGateways.find((g: { name: string }) => g.name === 'new-gateway'); + expect(gateway.targets).toHaveLength(2); + expect(gateway.targets[0]!.name).toBe('target-a'); + expect(gateway.targets[1]!.name).toBe('target-c'); + expect(written.unassignedTargets).toHaveLength(1); + expect(written.unassignedTargets[0]!.name).toBe('target-b'); + }); + + it('creates gateway with empty targets when no selectedTargets', async () => { + mockConfigExists.mockReturnValue(true); + mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); + + await createGatewayFromWizard(makeGatewayConfig()); - it('replaces hyphens with underscores', () => { - expect(computeDefaultGatewayEnvVarName('my-gateway')).toBe('AGENTCORE_GATEWAY_MY_GATEWAY_URL'); + const written = mockWriteMcpSpec.mock.calls[0]![0]; + const gateway = written.agentCoreGateways.find((g: { name: string }) => g.name === 'new-gateway'); + expect(gateway.targets).toHaveLength(0); }); }); diff --git a/src/cli/tui/screens/mcp/__tests__/types.test.ts b/src/cli/tui/screens/mcp/__tests__/types.test.ts index 31c1e9db7..cac8e71f6 100644 --- a/src/cli/tui/screens/mcp/__tests__/types.test.ts +++ b/src/cli/tui/screens/mcp/__tests__/types.test.ts @@ -1,4 +1,4 @@ -import { AUTHORIZER_TYPE_OPTIONS, SKIP_FOR_NOW, SOURCE_OPTIONS } from '../types.js'; +import { AUTHORIZER_TYPE_OPTIONS, SKIP_FOR_NOW, TARGET_TYPE_OPTIONS } from '../types.js'; import { describe, expect, it } from 'vitest'; describe('MCP types constants', () => { @@ -10,11 +10,8 @@ describe('MCP types constants', () => { expect(SKIP_FOR_NOW).toBe('skip-for-now'); }); - it('SOURCE_OPTIONS has entries for existing-endpoint and create-new', () => { - const existingEndpoint = SOURCE_OPTIONS.find((opt: { id: string }) => opt.id === 'existing-endpoint'); - const createNew = SOURCE_OPTIONS.find((opt: { id: string }) => opt.id === 'create-new'); - - expect(existingEndpoint).toBeDefined(); - expect(createNew).toBeDefined(); + it('TARGET_TYPE_OPTIONS has mcpServer entry', () => { + const mcpServer = TARGET_TYPE_OPTIONS.find((opt: { id: string }) => opt.id === 'mcpServer'); + expect(mcpServer).toBeDefined(); }); }); From 89e1e310b51d5cc80a90dd60ee66c912db79cf66 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 4 Mar 2026 21:17:34 -0500 Subject: [PATCH 4/7] docs: update gateway-target examples to use --type mcp-server - Replace --source existing-endpoint with --type mcp-server in all examples - Update flags table: --source -> --type (required) - Update commands.md, gateway.md, and local-development.md --- docs/commands.md | 8 ++++---- docs/gateway.md | 10 +++++----- docs/local-development.md | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 0e8c6b5fc..4f9816ba9 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -198,14 +198,14 @@ agentcore add # External MCP server endpoint agentcore add gateway-target \ --name WeatherTools \ - --source existing-endpoint \ + --type mcp-server \ --endpoint https://mcp.example.com/mcp \ --gateway MyGateway # External endpoint with OAuth outbound auth agentcore add gateway-target \ --name SecureTools \ - --source existing-endpoint \ + --type mcp-server \ --endpoint https://api.example.com/mcp \ --gateway MyGateway \ --outbound-auth oauth \ @@ -218,7 +218,7 @@ agentcore add gateway-target \ | -------------------------------- | ----------------------------------------------- | | `--name ` | Target name | | `--description ` | Target description | -| `--source ` | `existing-endpoint` | +| `--type ` | Target type (required): `mcp-server` | | `--endpoint ` | MCP server endpoint URL | | `--gateway ` | Gateway to attach target to | | `--outbound-auth ` | `oauth`, `api-key`, or `none` | @@ -382,7 +382,7 @@ agentcore deploy -y agentcore add gateway --name MyGateway agentcore add gateway-target \ --name WeatherTools \ - --source existing-endpoint \ + --type mcp-server \ --endpoint https://mcp.example.com/mcp \ --gateway MyGateway agentcore deploy -y diff --git a/docs/gateway.md b/docs/gateway.md index 2ddc1c210..4f47e72a4 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -18,7 +18,7 @@ agentcore add gateway --name my-gateway # 3. Add a target (external MCP server) agentcore add gateway-target \ - --source existing-endpoint \ + --type mcp-server \ --name weather-tools \ --endpoint https://mcp.example.com/mcp \ --gateway my-gateway @@ -39,7 +39,7 @@ requests to. ```bash agentcore add gateway-target \ - --source existing-endpoint \ + --type mcp-server \ --name my-tools \ --endpoint https://mcp.example.com/mcp \ --gateway my-gateway @@ -87,7 +87,7 @@ Controls how the gateway authenticates with upstream MCP servers. Configured per ```bash agentcore add gateway-target \ - --source existing-endpoint \ + --type mcp-server \ --name secure-tools \ --endpoint https://api.example.com/mcp \ --gateway my-gateway \ @@ -108,7 +108,7 @@ agentcore add identity \ --client-secret my-secret agentcore add gateway-target \ - --source existing-endpoint \ + --type mcp-server \ --name secure-tools \ --endpoint https://api.example.com/mcp \ --gateway my-gateway \ @@ -129,7 +129,7 @@ include gateway client code with the correct authentication for your framework. # 1. Add gateway and targets agentcore add gateway --name my-gateway agentcore add gateway-target \ - --source existing-endpoint \ + --type mcp-server \ --name my-tools \ --endpoint https://mcp.example.com/mcp \ --gateway my-gateway diff --git a/docs/local-development.md b/docs/local-development.md index c9b464eb1..284bfd6ab 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -142,7 +142,7 @@ populated by `agentcore deploy`. If you haven't deployed yet, no gateway env var ```bash # 1. Add a gateway and target agentcore add gateway --name my-gateway -agentcore add gateway-target --name my-tools --source existing-endpoint \ +agentcore add gateway-target --name my-tools --type mcp-server \ --endpoint https://mcp.example.com/mcp --gateway my-gateway # 2. Deploy to create the gateway From 321f69b6c6f4a558f02cfea3142c8c9ea2739db0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 4 Mar 2026 21:56:36 -0500 Subject: [PATCH 5/7] fix: reset command.tsx and create-mcp.test.ts to main versions These files were incorrectly carried from our pre-rebase branch during conflict resolution (--theirs/--ours reversed in rebase context). The modular primitive PR moved their contents to GatewayTargetPrimitive.ts. --- src/cli/commands/add/command.tsx | 350 ++---------------- .../mcp/__tests__/create-mcp.test.ts | 178 +-------- 2 files changed, 31 insertions(+), 497 deletions(-) diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index b40487a95..c11df2cce 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -1,344 +1,40 @@ import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject } from '../../tui/guards'; import { AddFlow } from '../../tui/screens/add/AddFlow'; -import { - handleAddAgent, - handleAddGateway, - handleAddGatewayTarget, - handleAddIdentity, - handleAddMemory, -} from './actions'; -import type { - AddAgentOptions, - AddGatewayOptions, - AddGatewayTargetOptions, - AddIdentityOptions, - AddMemoryOptions, -} from './types'; -import { - validateAddAgentOptions, - validateAddGatewayOptions, - validateAddGatewayTargetOptions, - validateAddIdentityOptions, - validateAddMemoryOptions, -} from './validate'; import type { Command } from '@commander-js/extra-typings'; import { render } from 'ink'; import React from 'react'; -async function handleAddAgentCLI(options: AddAgentOptions): Promise { - const validation = validateAddAgentOptions(options); - if (!validation.valid) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: validation.error })); - } else { - console.error(validation.error); - } - process.exit(1); - } - - const result = await handleAddAgent({ - name: options.name!, - type: options.type! ?? 'create', - buildType: (options.build as 'CodeZip' | 'Container') ?? 'CodeZip', - language: options.language!, - framework: options.framework!, - modelProvider: options.modelProvider!, - apiKey: options.apiKey, - memory: options.memory, - codeLocation: options.codeLocation, - entrypoint: options.entrypoint, - }); - - if (options.json) { - console.log(JSON.stringify(result)); - } else if (result.success) { - console.log(`Added agent '${result.agentName}'`); - if (result.agentPath) { - console.log(`Agent code: ${result.agentPath}`); - } - } else { - console.error(result.error); - } - - process.exit(result.success ? 0 : 1); -} - -async function handleAddGatewayCLI(options: AddGatewayOptions): Promise { - const validation = validateAddGatewayOptions(options); - if (!validation.valid) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: validation.error })); - } else { - console.error(validation.error); - } - process.exit(1); - } - - const result = await handleAddGateway({ - name: options.name!, - description: options.description, - authorizerType: options.authorizerType ?? 'NONE', - discoveryUrl: options.discoveryUrl, - allowedAudience: options.allowedAudience, - allowedClients: options.allowedClients, - allowedScopes: options.allowedScopes, - agentClientId: options.agentClientId, - agentClientSecret: options.agentClientSecret, - agents: options.agents, - }); - - if (options.json) { - console.log(JSON.stringify(result)); - } else if (result.success) { - console.log(`Added gateway '${result.gatewayName}'`); - } else { - console.error(result.error); - } - - process.exit(result.success ? 0 : 1); -} - -async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Promise { - const validation = await validateAddGatewayTargetOptions(options); - if (!validation.valid) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: validation.error })); - } else { - console.error(validation.error); - } - process.exit(1); - } - - // Map CLI flag values to internal types - const outboundAuthMap: Record = { - oauth: 'OAUTH', - 'api-key': 'API_KEY', - none: 'NONE', - }; - - const result = await handleAddGatewayTarget({ - name: options.name!, - description: options.description, - type: options.type!, - endpoint: options.endpoint, - language: options.language! as 'Python' | 'TypeScript', - gateway: options.gateway, - host: options.host, - outboundAuthType: options.outboundAuthType ? outboundAuthMap[options.outboundAuthType.toLowerCase()] : undefined, - credentialName: options.credentialName, - oauthClientId: options.oauthClientId, - oauthClientSecret: options.oauthClientSecret, - oauthDiscoveryUrl: options.oauthDiscoveryUrl, - oauthScopes: options.oauthScopes, - }); - - if (options.json) { - console.log(JSON.stringify(result)); - } else if (result.success) { - console.log(`Added gateway target '${result.toolName}'`); - if (result.sourcePath) { - console.log(`Tool code: ${result.sourcePath}`); - } - } else { - console.error(result.error); - } - - process.exit(result.success ? 0 : 1); -} - -// v2: Memory is a top-level resource (no owner/user) -async function handleAddMemoryCLI(options: AddMemoryOptions): Promise { - const validation = validateAddMemoryOptions(options); - if (!validation.valid) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: validation.error })); - } else { - console.error(validation.error); - } - process.exit(1); - } - - const result = await handleAddMemory({ - name: options.name!, - strategies: options.strategies, - expiry: options.expiry, - }); - - if (options.json) { - console.log(JSON.stringify(result)); - } else if (result.success) { - console.log(`Added memory '${result.memoryName}'`); - } else { - console.error(result.error); - } - - process.exit(result.success ? 0 : 1); -} - -// v2: Identity/Credential is a top-level resource (no owner/user) -async function handleAddIdentityCLI(options: AddIdentityOptions): Promise { - const validation = validateAddIdentityOptions(options); - if (!validation.valid) { - if (options.json) { - console.log(JSON.stringify({ success: false, error: validation.error })); - } else { - console.error(validation.error); - } - process.exit(1); - } - - const identityType = options.type ?? 'api-key'; - const result = - identityType === 'oauth' - ? await handleAddIdentity({ - type: 'oauth', - name: options.name!, - discoveryUrl: options.discoveryUrl!, - clientId: options.clientId!, - clientSecret: options.clientSecret!, - scopes: options.scopes, - }) - : await handleAddIdentity({ - type: 'api-key', - name: options.name!, - apiKey: options.apiKey!, - }); - - if (options.json) { - console.log(JSON.stringify(result)); - } else if (result.success) { - console.log(`Added credential '${result.credentialName}'`); - } else { - console.error(result.error); - } - - process.exit(result.success ? 0 : 1); -} - -export function registerAdd(program: Command) { +export function registerAdd(program: Command): Command { const addCmd = program .command('add') .description(COMMAND_DESCRIPTIONS.add) - // Catch-all argument for invalid subcommands - Commander matches subcommands first - .argument('[subcommand]') - .action((subcommand: string | undefined, _options, cmd) => { - if (subcommand) { - console.error(`error: '${subcommand}' is not a valid subcommand.`); - cmd.outputHelp(); - process.exit(1); - } - - requireProject(); - - const { clear, unmount } = render( - { - clear(); - unmount(); - }} - /> - ); - }) .showHelpAfterError() .showSuggestionAfterError(); - // Subcommand: add agent - addCmd - .command('agent') - .description('Add an agent to the project') - .option('--name ', 'Agent name (start with letter, alphanumeric only, max 64 chars) [non-interactive]') - .option('--type ', 'Agent type: create or byo [non-interactive]', 'create') - .option('--build ', 'Build type: CodeZip or Container (default: CodeZip) [non-interactive]') - .option('--language ', 'Language: Python (create), or Python/TypeScript/Other (BYO) [non-interactive]') - .option( - '--framework ', - 'Framework: Strands, LangChain_LangGraph, CrewAI, GoogleADK, OpenAIAgents [non-interactive]' - ) - .option('--model-provider ', 'Model provider: Bedrock, Anthropic, OpenAI, Gemini [non-interactive]') - .option('--api-key ', 'API key for non-Bedrock providers [non-interactive]') - .option('--memory ', 'Memory: none, shortTerm, longAndShortTerm (create path only) [non-interactive]') - .option('--code-location ', 'Path to existing code (BYO path only) [non-interactive]') - .option('--entrypoint ', 'Entry file relative to code-location (BYO, default: main.py) [non-interactive]') - .option('--json', 'Output as JSON [non-interactive]') - .action(async options => { - requireProject(); - await handleAddAgentCLI(options as AddAgentOptions); - }); - - // Subcommand: add gateway - addCmd - .command('gateway') - .description('Add a gateway to the project') - .option('--name ', 'Gateway name') - .option('--description ', 'Gateway description') - .option('--authorizer-type ', 'Authorizer type: NONE or CUSTOM_JWT', 'NONE') - .option('--discovery-url ', 'OIDC discovery URL (required for CUSTOM_JWT)') - .option('--allowed-audience ', 'Comma-separated allowed audience values (required for CUSTOM_JWT)') - .option('--allowed-clients ', 'Comma-separated allowed client IDs (required for CUSTOM_JWT)') - .option('--allowed-scopes ', 'Comma-separated allowed scopes (optional for CUSTOM_JWT)') - .option('--agent-client-id ', 'Agent OAuth client ID for Bearer token auth (CUSTOM_JWT)') - .option('--agent-client-secret ', 'Agent OAuth client secret (CUSTOM_JWT)') - .option('--json', 'Output as JSON') - .action(async options => { - requireProject(); - await handleAddGatewayCLI(options as AddGatewayOptions); - }); + // Catch-all argument for invalid subcommands - Commander matches subcommands first + addCmd.argument('[subcommand]').action((subcommand: string | undefined, _options, cmd) => { + if (subcommand) { + console.error(`error: '${subcommand}' is not a valid subcommand.`); + cmd.outputHelp(); + process.exit(1); + } - // Subcommand: add gateway-target - addCmd - .command('gateway-target') - .description('Add a gateway target to the project') - .option('--name ', 'Tool name') - .option('--description ', 'Tool description') - .option('--type ', 'Target type (required): mcp-server') - .option('--endpoint ', 'MCP server endpoint URL') - .option('--language ', 'Language: Python or TypeScript') - .option('--gateway ', 'Gateway name') - .option('--host ', 'Compute host: Lambda or AgentCoreRuntime') - .option('--outbound-auth ', 'Outbound auth type: oauth, api-key, or none') - .option('--credential-name ', 'Existing credential name for outbound auth') - .option('--oauth-client-id ', 'OAuth client ID (creates credential inline)') - .option('--oauth-client-secret ', 'OAuth client secret (creates credential inline)') - .option('--oauth-discovery-url ', 'OAuth discovery URL (creates credential inline)') - .option('--oauth-scopes ', 'OAuth scopes, comma-separated') - .option('--json', 'Output as JSON') - .action(async options => { - requireProject(); - await handleAddGatewayTargetCLI(options as AddGatewayTargetOptions); - }); + requireProject(); + + const { clear, unmount } = render( + { + clear(); + unmount(); + }} + /> + ); + }); - // Subcommand: add memory (v2: top-level resource) - addCmd - .command('memory') - .description('Add a memory resource to the project') - .option('--name ', 'Memory name [non-interactive]') - .option( - '--strategies ', - 'Comma-separated strategies: SEMANTIC, SUMMARIZATION, USER_PREFERENCE [non-interactive]' - ) - .option('--expiry ', 'Event expiry duration in days (default: 30) [non-interactive]', parseInt) - .option('--json', 'Output as JSON [non-interactive]') - .action(async options => { - requireProject(); - await handleAddMemoryCLI(options as AddMemoryOptions); - }); + // Subcommands (agent, memory, identity, gateway, gateway-target) are registered + // via primitive.registerCommands() in cli.ts - // Subcommand: add identity (v2: top-level credential resource) - addCmd - .command('identity') - .description('Add a credential to the project') - .option('--name ', 'Credential name [non-interactive]') - .option('--type ', 'Credential type: api-key (default) or oauth') - .option('--api-key ', 'The API key value [non-interactive]') - .option('--discovery-url ', 'OAuth discovery URL') - .option('--client-id ', 'OAuth client ID') - .option('--client-secret ', 'OAuth client secret') - .option('--scopes ', 'OAuth scopes, comma-separated') - .option('--json', 'Output as JSON [non-interactive]') - .action(async options => { - requireProject(); - await handleAddIdentityCLI(options as AddIdentityOptions); - }); + return addCmd; } diff --git a/src/cli/operations/mcp/__tests__/create-mcp.test.ts b/src/cli/operations/mcp/__tests__/create-mcp.test.ts index a323db8d7..eac2ffc79 100644 --- a/src/cli/operations/mcp/__tests__/create-mcp.test.ts +++ b/src/cli/operations/mcp/__tests__/create-mcp.test.ts @@ -1,176 +1,14 @@ -import type { AddGatewayConfig, AddGatewayTargetConfig } from '../../../tui/screens/mcp/types.js'; -import { createExternalGatewayTarget, createGatewayFromWizard, getUnassignedTargets } from '../create-mcp.js'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { GatewayPrimitive } from '../../../primitives/GatewayPrimitive.js'; +import { describe, expect, it } from 'vitest'; -const { mockReadMcpSpec, mockWriteMcpSpec, mockConfigExists, mockReadProjectSpec } = vi.hoisted(() => ({ - mockReadMcpSpec: vi.fn(), - mockWriteMcpSpec: vi.fn(), - mockConfigExists: vi.fn(), - mockReadProjectSpec: vi.fn(), -})); +const computeDefaultGatewayEnvVarName = (name: string) => GatewayPrimitive.computeDefaultGatewayEnvVarName(name); -vi.mock('../../../../lib/index.js', () => ({ - ConfigIO: class { - configExists = mockConfigExists; - readMcpSpec = mockReadMcpSpec; - writeMcpSpec = mockWriteMcpSpec; - readProjectSpec = mockReadProjectSpec; - }, -})); - -function makeExternalConfig(overrides: Partial = {}): AddGatewayTargetConfig { - return { - name: 'test-target', - description: 'Test target', - sourcePath: '/tmp/test', - language: 'Other', - targetType: 'mcpServer', - endpoint: 'https://api.example.com', - gateway: 'test-gateway', - host: 'Lambda', - toolDefinition: { name: 'test-tool', description: 'Test tool' }, - ...overrides, - } as AddGatewayTargetConfig; -} - -describe('createExternalGatewayTarget', () => { - afterEach(() => vi.clearAllMocks()); - - it('creates target with endpoint and assigns to specified gateway', async () => { - const mockMcpSpec = { - agentCoreGateways: [{ name: 'test-gateway', targets: [] }], - }; - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue(mockMcpSpec); - - await createExternalGatewayTarget(makeExternalConfig()); - - expect(mockWriteMcpSpec).toHaveBeenCalled(); - const written = mockWriteMcpSpec.mock.calls[0]![0]; - const gateway = written.agentCoreGateways[0]!; - expect(gateway.targets).toHaveLength(1); - expect(gateway.targets[0]!.name).toBe('test-target'); - expect(gateway.targets[0]!.endpoint).toBe('https://api.example.com'); - expect(gateway.targets[0]!.targetType).toBe('mcpServer'); - }); - - it('throws when gateway is not provided', async () => { - const mockMcpSpec = { agentCoreGateways: [{ name: 'test-gateway', targets: [] }] }; - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue(mockMcpSpec); - - await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: undefined }))).rejects.toThrow( - 'Gateway is required' - ); - }); - - it('throws on duplicate target name in gateway', async () => { - const mockMcpSpec = { - agentCoreGateways: [{ name: 'test-gateway', targets: [{ name: 'test-target' }] }], - }; - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue(mockMcpSpec); - - await expect(createExternalGatewayTarget(makeExternalConfig())).rejects.toThrow( - 'Target "test-target" already exists in gateway "test-gateway"' - ); - }); - - it('throws when gateway not found', async () => { - const mockMcpSpec = { agentCoreGateways: [] }; - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue(mockMcpSpec); - - await expect(createExternalGatewayTarget(makeExternalConfig({ gateway: 'nonexistent' }))).rejects.toThrow( - 'Gateway "nonexistent" not found' - ); - }); - - it('includes outboundAuth when configured', async () => { - const mockMcpSpec = { - agentCoreGateways: [{ name: 'test-gateway', targets: [] }], - }; - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue(mockMcpSpec); - - await createExternalGatewayTarget( - makeExternalConfig({ outboundAuth: { type: 'API_KEY', credentialName: 'my-cred' } }) - ); - - const written = mockWriteMcpSpec.mock.calls[0]![0]; - const target = written.agentCoreGateways[0]!.targets[0]!; - expect(target.outboundAuth).toEqual({ type: 'API_KEY', credentialName: 'my-cred' }); +describe('computeDefaultGatewayEnvVarName', () => { + it('converts simple name to env var', () => { + expect(computeDefaultGatewayEnvVarName('mygateway')).toBe('AGENTCORE_GATEWAY_MYGATEWAY_URL'); }); -}); - -describe('getUnassignedTargets', () => { - afterEach(() => vi.clearAllMocks()); - - it('returns unassigned targets from mcp spec', async () => { - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue({ - agentCoreGateways: [], - unassignedTargets: [{ name: 't1' }, { name: 't2' }], - }); - - const result = await getUnassignedTargets(); - expect(result).toHaveLength(2); - expect(result[0]!.name).toBe('t1'); - }); - - it('returns empty array when no mcp config exists', async () => { - mockConfigExists.mockReturnValue(false); - expect(await getUnassignedTargets()).toEqual([]); - }); - - it('returns empty array when unassignedTargets field is missing', async () => { - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); - expect(await getUnassignedTargets()).toEqual([]); - }); -}); - -describe('createGatewayFromWizard with selectedTargets', () => { - afterEach(() => vi.clearAllMocks()); - - function makeGatewayConfig(overrides: Partial = {}): AddGatewayConfig { - return { - name: 'new-gateway', - authorizerType: 'AWS_IAM', - ...overrides, - } as AddGatewayConfig; - } - - it('moves selected targets to new gateway and removes from unassigned', async () => { - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue({ - agentCoreGateways: [], - unassignedTargets: [ - { name: 'target-a', targetType: 'mcpServer' }, - { name: 'target-b', targetType: 'mcpServer' }, - { name: 'target-c', targetType: 'mcpServer' }, - ], - }); - - await createGatewayFromWizard(makeGatewayConfig({ selectedTargets: ['target-a', 'target-c'] })); - - const written = mockWriteMcpSpec.mock.calls[0]![0]; - const gateway = written.agentCoreGateways.find((g: { name: string }) => g.name === 'new-gateway'); - expect(gateway.targets).toHaveLength(2); - expect(gateway.targets[0]!.name).toBe('target-a'); - expect(gateway.targets[1]!.name).toBe('target-c'); - expect(written.unassignedTargets).toHaveLength(1); - expect(written.unassignedTargets[0]!.name).toBe('target-b'); - }); - - it('creates gateway with empty targets when no selectedTargets', async () => { - mockConfigExists.mockReturnValue(true); - mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); - - await createGatewayFromWizard(makeGatewayConfig()); - const written = mockWriteMcpSpec.mock.calls[0]![0]; - const gateway = written.agentCoreGateways.find((g: { name: string }) => g.name === 'new-gateway'); - expect(gateway.targets).toHaveLength(0); + it('replaces hyphens with underscores', () => { + expect(computeDefaultGatewayEnvVarName('my-gateway')).toBe('AGENTCORE_GATEWAY_MY_GATEWAY_URL'); }); }); From dafed52553470ae4c15b309e4490db3a05ef6768 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 5 Mar 2026 09:37:41 -0500 Subject: [PATCH 6/7] ci: retrigger checks From 4dfdcf2dae19fd3e16c1a2720c37e6105fae3f55 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 5 Mar 2026 10:40:34 -0500 Subject: [PATCH 7/7] test: add missing --type flag to remove gateway-target tests --- src/cli/commands/remove/__tests__/remove-gateway-target.test.ts | 2 ++ src/cli/commands/remove/__tests__/remove-gateway.test.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts index 67130423a..4269de872 100644 --- a/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts +++ b/src/cli/commands/remove/__tests__/remove-gateway-target.test.ts @@ -63,6 +63,8 @@ describe('remove gateway-target command', () => { 'https://example.com/mcp', '--gateway', tempGateway, + '--type', + 'mcp-server', '--json', ], projectDir diff --git a/src/cli/commands/remove/__tests__/remove-gateway.test.ts b/src/cli/commands/remove/__tests__/remove-gateway.test.ts index a2d273e26..b2dfd1f62 100644 --- a/src/cli/commands/remove/__tests__/remove-gateway.test.ts +++ b/src/cli/commands/remove/__tests__/remove-gateway.test.ts @@ -108,6 +108,8 @@ describe('remove gateway command', () => { 'https://example.com/mcp', '--gateway', gatewayName, + '--type', + 'mcp-server', '--json', ], projectDir