diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index cf83d3e6..f52723cc 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -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; @@ -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, diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 1e7a2170..89e8125f 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -262,6 +262,9 @@ export function registerAdd(program: Command) { .description('Add a gateway target to the project') .option('--name ', 'Tool name') .option('--description ', 'Tool description') + .option('--type ', 'Target type: mcpServer or lambda') + .option('--source ', 'Source: existing-endpoint or create-new') + .option('--endpoint ', 'MCP server endpoint URL') .option('--language ', 'Language: Python or TypeScript') .option('--exposure ', 'Exposure mode: mcp-runtime or behind-gateway') .option('--agents ', 'Comma-separated agent names (for mcp-runtime)') diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index bdab405d..984351c0 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -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; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 2934b25d..2331b369 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -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' }; + } + + 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' }; } diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index c4ef2607..fe517515 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -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'; @@ -198,6 +203,57 @@ async function validateCredentialName(credentialName: string): Promise { } } +/** + * Create an external MCP server target (existing endpoint). + */ +export async function createExternalGatewayTarget(config: AddGatewayTargetConfig): Promise { + 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). */ diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx index 28ec0a91..d31fde83 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -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'; @@ -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' }); + }); + } 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] ); diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index f3a5c45f..6a447420 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -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'; @@ -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 })), [] @@ -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] ); @@ -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), @@ -132,6 +155,15 @@ export function AddGatewayTargetScreen({ return ( + {isSourceStep && ( + + )} + {isLanguageStep && ( )} @@ -180,12 +212,29 @@ export function AddGatewayTargetScreen({ {isTextStep && ( (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 + } /> )} @@ -193,14 +242,24 @@ export function AddGatewayTargetScreen({ 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 }] : []), ]} /> )} diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 3c781d32..6307e77b 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -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'; @@ -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) */ @@ -75,6 +88,8 @@ export interface AddGatewayTargetConfig { export const MCP_TOOL_STEP_LABELS: Record = { name: 'Name', + source: 'Source', + endpoint: 'Endpoint', language: 'Language', exposure: 'Exposure', agents: 'Agents', @@ -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' }, diff --git a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts index 2deda24e..e5934fd0 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -1,18 +1,23 @@ import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; import type { ToolDefinition } from '../../../../schema'; import type { AddGatewayTargetConfig, AddGatewayTargetStep, ComputeHost, ExposureMode, TargetLanguage } from './types'; +import { SKIP_FOR_NOW } from './types'; import { useCallback, useMemo, useState } from 'react'; /** - * Dynamic steps based on exposure mode. - * - MCP Runtime: name → language → exposure → agents → confirm - * - Behind gateway: name → language → exposure → gateway → host → confirm + * Dynamic steps based on exposure mode and source. + * - Existing endpoint: name → source → endpoint → gateway → confirm + * - Create new MCP Runtime: name → source → language → exposure → agents → confirm + * - Create new Behind gateway: name → source → language → exposure → gateway → host → confirm */ -function getSteps(exposure: ExposureMode): AddGatewayTargetStep[] { +function getSteps(exposure: ExposureMode, source?: 'existing-endpoint' | 'create-new'): AddGatewayTargetStep[] { + if (source === 'existing-endpoint') { + return ['name', 'source', 'endpoint', 'gateway', 'confirm']; + } if (exposure === 'mcp-runtime') { - return ['name', 'language', 'exposure', 'agents', 'confirm']; + return ['name', 'source', 'language', 'exposure', 'agents', 'confirm']; } - return ['name', 'language', 'exposure', 'gateway', 'host', 'confirm']; + return ['name', 'source', 'language', 'exposure', 'gateway', 'host', 'confirm']; } function deriveToolDefinition(name: string): ToolDefinition { @@ -40,16 +45,16 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); - const steps = useMemo(() => getSteps(config.exposure), [config.exposure]); + const steps = useMemo(() => getSteps(config.exposure, config.source), [config.exposure, config.source]); const currentIndex = steps.indexOf(step); const goBack = useCallback(() => { - // Recalculate steps in case exposure changed - const currentSteps = getSteps(config.exposure); + // Recalculate steps in case exposure or source changed + const currentSteps = getSteps(config.exposure, config.source); const idx = currentSteps.indexOf(step); const prevStep = currentSteps[idx - 1]; if (prevStep) setStep(prevStep); - }, [config.exposure, step]); + }, [config.exposure, config.source, step]); const setName = useCallback((name: string) => { setConfig(c => ({ @@ -59,7 +64,27 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist sourcePath: `${APP_DIR}/${MCP_APP_SUBDIR}/${name}`, toolDefinition: deriveToolDefinition(name), })); - setStep('language'); + setStep('source'); + }, []); + + const setSource = useCallback((source: 'existing-endpoint' | 'create-new') => { + setConfig(c => ({ + ...c, + source, + })); + if (source === 'existing-endpoint') { + setStep('endpoint'); + } else { + setStep('language'); + } + }, []); + + const setEndpoint = useCallback((endpoint: string) => { + setConfig(c => ({ + ...c, + endpoint, + })); + setStep('gateway'); }, []); const setLanguage = useCallback((language: TargetLanguage) => { @@ -101,11 +126,16 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist }, []); const setGateway = useCallback((gateway: string) => { - setConfig(c => ({ - ...c, - gateway, - })); - setStep('host'); + setConfig(c => { + const isExternal = c.source === 'existing-endpoint'; + const isSkipped = gateway === SKIP_FOR_NOW; + if (isExternal || isSkipped) { + setStep('confirm'); + } else { + setStep('host'); + } + return { ...c, gateway: isSkipped ? undefined : gateway }; + }); }, []); const setHost = useCallback((host: ComputeHost) => { @@ -130,6 +160,8 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = [], exist existingAgents, goBack, setName, + setSource, + setEndpoint, setLanguage, setExposure, setAgents, diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index f529da4a..c5047bf1 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -403,6 +403,7 @@ export const AgentCoreMcpSpecSchema = z .object({ agentCoreGateways: z.array(AgentCoreGatewaySchema), mcpRuntimeTools: z.array(AgentCoreMcpRuntimeToolSchema).optional(), + unassignedTargets: z.array(AgentCoreGatewayTargetSchema).optional(), }) .strict();