diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 4d78619d..085052e1 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -263,6 +263,36 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO }; } + // API Gateway targets: validate early and return (skip outbound auth validation) + if (mappedType === 'apiGateway') { + if (!options.restApiId) { + return { valid: false, error: '--rest-api-id is required for api-gateway type' }; + } + if (!options.stage) { + return { valid: false, error: '--stage is required for api-gateway type' }; + } + if (options.endpoint) { + return { valid: false, error: '--endpoint is not applicable for api-gateway type' }; + } + if (options.host) { + return { valid: false, error: '--host is not applicable for api-gateway type' }; + } + if (options.language && options.language !== 'Other') { + return { valid: false, error: '--language is not applicable for api-gateway type' }; + } + if (options.outboundAuthType) { + return { valid: false, error: '--outbound-auth is not applicable for api-gateway type' }; + } + if (options.credentialName) { + return { valid: false, error: '--credential-name is not applicable for api-gateway type' }; + } + if (options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl || options.oauthScopes) { + return { valid: false, error: 'OAuth options are not applicable for api-gateway type' }; + } + options.language = 'Other'; + return { valid: true }; + } + // Validate outbound auth configuration if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { const hasInlineOAuth = !!(options.oauthClientId ?? options.oauthClientSecret ?? options.oauthDiscoveryUrl); @@ -309,35 +339,6 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } } - if (mappedType === 'apiGateway') { - if (!options.restApiId) { - return { valid: false, error: '--rest-api-id is required for api-gateway type' }; - } - if (!options.stage) { - return { valid: false, error: '--stage is required for api-gateway type' }; - } - if (options.endpoint) { - return { valid: false, error: '--endpoint is not applicable for api-gateway type' }; - } - if (options.host) { - return { valid: false, error: '--host is not applicable for api-gateway type' }; - } - if (options.language && options.language !== 'Other') { - return { valid: false, error: '--language is not applicable for api-gateway type' }; - } - if (options.outboundAuthType) { - return { valid: false, error: '--outbound-auth is not applicable for api-gateway type' }; - } - if (options.credentialName) { - return { valid: false, error: '--credential-name is not applicable for api-gateway type' }; - } - if (options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl || options.oauthScopes) { - return { valid: false, error: 'OAuth options are not applicable for api-gateway type' }; - } - options.language = 'Other'; - return { valid: true }; - } - if (mappedType === 'mcpServer') { if (options.host) { return { valid: false, error: '--host is not applicable for MCP server targets' }; diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index b3d71570..b1483abd 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -3,6 +3,7 @@ import type { AgentCoreCliMcpDefs, AgentCoreGatewayTarget, AgentCoreMcpSpec, + ApiGatewayHttpMethod, DirectoryPath, FilePath, } from '../../schema'; @@ -13,7 +14,7 @@ import { getErrorMessage } from '../errors'; import type { RemovableGatewayTarget } from '../operations/remove/remove-gateway-target'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; import { getTemplateToolDefinitions, renderGatewayTargetTemplate } from '../templates/GatewayTargetRenderer'; -import type { AddGatewayTargetConfig } from '../tui/screens/mcp/types'; +import type { ApiGatewayTargetConfig, GatewayTargetWizardState, McpServerTargetConfig } from '../tui/screens/mcp/types'; import { DEFAULT_HANDLER, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION } from '../tui/screens/mcp/types'; import { BasePrimitive } from './BasePrimitive'; import { SOURCE_CODE_NOTE } from './constants'; @@ -279,26 +280,19 @@ export class GatewayTargetPrimitive extends BasePrimitive m.trim()) ?? ['GET'], + methods: (cliOptions.toolFilterMethods?.split(',').map(m => m.trim()) ?? [ + 'GET', + ]) as ApiGatewayHttpMethod[], }, ] : undefined, @@ -315,20 +309,17 @@ export class GatewayTargetPrimitive extends BasePrimitive { - if (!config.endpoint) { - throw new Error('Endpoint URL is required for external MCP server targets.'); - } - + async createExternalGatewayTarget(config: McpServerTargetConfig): Promise<{ toolName: string; projectPath: string }> { const mcpSpec: AgentCoreMcpSpec = this.configIO.configExists('mcp') ? await this.configIO.readMcpSpec() : { agentCoreGateways: [] }; const target: AgentCoreGatewayTarget = { name: config.name, - targetType: config.targetType ?? 'mcpServer', + targetType: 'mcpServer', endpoint: config.endpoint, toolDefinitions: [config.toolDefinition], ...(config.outboundAuth && { outboundAuth: config.outboundAuth }), @@ -494,17 +479,7 @@ export class GatewayTargetPrimitive extends BasePrimitive { - if (!config.restApiId) { - throw new Error('REST API ID is required for API Gateway targets.'); - } - if (!config.stage) { - throw new Error('Stage is required for API Gateway targets.'); - } - if (!config.gateway) { - throw new Error('Gateway name is required.'); - } - + async createApiGatewayTarget(config: ApiGatewayTargetConfig): Promise<{ toolName: string }> { const mcpSpec: AgentCoreMcpSpec = this.configIO.configExists('mcp') ? await this.configIO.readMcpSpec() : { agentCoreGateways: [] }; @@ -529,10 +504,7 @@ export class GatewayTargetPrimitive extends BasePrimitive { - this.validateGatewayTargetLanguage(config.language); + this.validateGatewayTargetLanguage(config.language!); const mcpSpec: AgentCoreMcpSpec = this.configIO.configExists('mcp') ? await this.configIO.readMcpSpec() : { agentCoreGateways: [] }; const toolDefs = - config.host === 'Lambda' ? getTemplateToolDefinitions(config.name, config.host) : [config.toolDefinition]; + config.host === 'Lambda' ? getTemplateToolDefinitions(config.name, config.host) : [config.toolDefinition!]; for (const toolDef of toolDefs) { ToolDefinitionSchema.parse(toolDef); @@ -615,7 +587,7 @@ export class GatewayTargetPrimitive extends BasePrimitive a.name === agentName) : allAgents; @@ -244,13 +251,13 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res identifier={rsEntry?.identifier} /> {targets.map(target => { - const displayText = - target.targetType === 'mcpServer' && target.endpoint ? target.endpoint : target.name; + const displayText = getTargetDisplayText(target); return ( {' '} {ICONS.tool} {displayText} - {target.targetType === 'mcpServer' && target.endpoint && ( + {(target.targetType === 'apiGateway' || + (target.targetType === 'mcpServer' && target.endpoint)) && ( [{target.targetType}] )} @@ -282,10 +289,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res ⚠ Unassigned Targets {unassignedTargets.map((target, idx) => { - const displayText = - target.targetType === 'mcpServer' && target.endpoint - ? target.endpoint - : (target.name ?? `Target ${idx + 1}`); + const displayText = getTargetDisplayText(target); return ; })} diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx index ec2a8e6a..15fc65e1 100644 --- a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx +++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx @@ -1,6 +1,6 @@ import type { AgentCoreMcpSpec, AgentCoreProjectSpec } from '../../../../schema/index.js'; import type { ResourceStatusEntry } from '../../../commands/status/action.js'; -import { ResourceGraph } from '../ResourceGraph.js'; +import { ResourceGraph, getTargetDisplayText } from '../ResourceGraph.js'; import { render } from 'ink-testing-library'; import React from 'react'; import { describe, expect, it } from 'vitest'; @@ -326,3 +326,34 @@ describe('ResourceGraph', () => { }); }); }); + +describe('getTargetDisplayText', () => { + it('returns endpoint for mcpServer with endpoint', () => { + const target = { name: 'my-tool', targetType: 'mcpServer', endpoint: 'https://example.com/mcp' } as any; + expect(getTargetDisplayText(target)).toBe('https://example.com/mcp'); + }); + + it('returns restApiId/stage for apiGateway', () => { + const target = { + name: 'my-api', + targetType: 'apiGateway', + apiGateway: { restApiId: 'abc123', stage: 'prod' }, + } as any; + expect(getTargetDisplayText(target)).toBe('abc123/prod'); + }); + + it('returns name for mcpServer without endpoint', () => { + const target = { name: 'my-tool', targetType: 'mcpServer' } as any; + expect(getTargetDisplayText(target)).toBe('my-tool'); + }); + + it('returns name for unknown target type', () => { + const target = { name: 'my-tool', targetType: 'lambda' } as any; + expect(getTargetDisplayText(target)).toBe('my-tool'); + }); + + it('returns name for apiGateway without apiGateway config', () => { + const target = { name: 'my-api', targetType: 'apiGateway' } as any; + expect(getTargetDisplayText(target)).toBe('my-api'); + }); +}); diff --git a/src/cli/tui/hooks/index.ts b/src/cli/tui/hooks/index.ts index 6138430f..a7eba80f 100644 --- a/src/cli/tui/hooks/index.ts +++ b/src/cli/tui/hooks/index.ts @@ -6,7 +6,7 @@ export { useExitHandler } from './useExitHandler'; export { useListNavigation } from './useListNavigation'; export { useMultiSelectNavigation } from './useMultiSelectNavigation'; export { useResponsive } from './useResponsive'; -export { useAvailableAgents, useCreateGateway, useCreateGatewayTarget, useExistingGateways } from './useCreateMcp'; +export { useAvailableAgents, useCreateGateway, useExistingGateways } from './useCreateMcp'; export { useDevServer } from './useDevServer'; export { useProject } from './useProject'; export type { UseProjectResult, ProjectContext } from './useProject'; diff --git a/src/cli/tui/hooks/useCreateMcp.ts b/src/cli/tui/hooks/useCreateMcp.ts index 924a13d0..52ac120f 100644 --- a/src/cli/tui/hooks/useCreateMcp.ts +++ b/src/cli/tui/hooks/useCreateMcp.ts @@ -1,17 +1,11 @@ import { agentPrimitive, gatewayPrimitive, gatewayTargetPrimitive } from '../../primitives/registry'; -import type { AddGatewayConfig, AddGatewayTargetConfig } from '../screens/mcp/types'; +import type { AddGatewayConfig } from '../screens/mcp/types'; import { useCallback, useEffect, useState } from 'react'; interface CreateGatewayResult { name: string; } -interface CreateToolResult { - mcpDefsPath: string; - toolName: string; - projectPath: string; -} - interface CreateStatus { state: 'idle' | 'loading' | 'success' | 'error'; error?: string; @@ -52,43 +46,6 @@ export function useCreateGateway() { return { status, createGateway, reset }; } -export function useCreateGatewayTarget() { - const [status, setStatus] = useState>({ state: 'idle' }); - - const createTool = useCallback(async (config: AddGatewayTargetConfig) => { - setStatus({ state: 'loading' }); - try { - const addResult = await gatewayTargetPrimitive.add({ - name: config.name, - description: config.description, - language: config.language, - gateway: config.gateway, - host: config.host, - }); - if (!addResult.success) { - throw new Error(addResult.error ?? 'Failed to create MCP tool'); - } - const result: CreateToolResult = { - mcpDefsPath: '', - toolName: addResult.toolName, - projectPath: addResult.sourcePath, - }; - setStatus({ state: 'success', result }); - return { ok: true as const, result }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to create gateway target.'; - setStatus({ state: 'error', error: message }); - return { ok: false as const, error: message }; - } - }, []); - - const reset = useCallback(() => { - setStatus({ state: 'idle' }); - }, []); - - return { status, createTool, reset }; -} - export function useExistingGateways() { const [gateways, setGateways] = useState([]); diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx index f35aff5a..a92a7733 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -1,17 +1,17 @@ import { gatewayTargetPrimitive } from '../../../primitives/registry'; import { ErrorPrompt } from '../../components'; -import { useCreateGatewayTarget, useExistingGateways, useExistingToolNames } from '../../hooks/useCreateMcp'; +import { useExistingGateways, useExistingToolNames } from '../../hooks/useCreateMcp'; import { AddSuccessScreen } from '../add/AddSuccessScreen'; import { AddIdentityScreen } from '../identity/AddIdentityScreen'; import type { AddIdentityConfig } from '../identity/types'; import { useCreateIdentity, useExistingCredentials, useExistingIdentityNames } from '../identity/useCreateIdentity'; import { AddGatewayTargetScreen } from './AddGatewayTargetScreen'; -import type { AddGatewayTargetConfig } from './types'; +import type { AddGatewayTargetConfig, GatewayTargetWizardState } from './types'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; type FlowState = | { name: 'create-wizard' } - | { name: 'creating-credential'; pendingConfig: AddGatewayTargetConfig } + | { name: 'creating-credential'; pendingConfig: GatewayTargetWizardState } | { name: 'create-success'; toolName: string; projectPath: string; loading?: boolean; loadingMessage?: string } | { name: 'error'; message: string }; @@ -33,7 +33,6 @@ export function AddGatewayTargetFlow({ onDev, onDeploy, }: AddGatewayTargetFlowProps) { - const { createTool, reset: resetCreate } = useCreateGatewayTarget(); const { gateways: existingGateways } = useExistingGateways(); const { toolNames: existingToolNames } = useExistingToolNames(); const { credentials } = useExistingCredentials(); @@ -53,40 +52,37 @@ export function AddGatewayTargetFlow({ } }, [isInteractive, flow, onExit]); - const handleCreateComplete = useCallback( - (config: AddGatewayTargetConfig) => { - setFlow({ - name: 'create-success', - toolName: config.name, - projectPath: '', - loading: true, - loadingMessage: 'Creating gateway target...', - }); + const handleCreateComplete = useCallback((config: AddGatewayTargetConfig) => { + setFlow({ + name: 'create-success', + toolName: config.name, + projectPath: '', + loading: true, + loadingMessage: 'Creating gateway target...', + }); - if (config.targetType === 'mcpServer') { - void gatewayTargetPrimitive - .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 }); + if (config.targetType === 'mcpServer') { + void gatewayTargetPrimitive + .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' }); }); - } - }, - [createTool] - ); + } else { + void gatewayTargetPrimitive + .createApiGatewayTarget(config) + .then((result: { toolName: string }) => { + setFlow({ name: 'create-success', toolName: result.toolName, projectPath: '' }); + }) + .catch((err: unknown) => { + setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); + }); + } + }, []); - const handleCreateCredential = useCallback((pendingConfig: AddGatewayTargetConfig) => { + const handleCreateCredential = useCallback((pendingConfig: GatewayTargetWizardState) => { setFlow({ name: 'creating-credential', pendingConfig }); }, []); @@ -113,11 +109,17 @@ export function AddGatewayTargetFlow({ void createIdentity(createConfig).then(result => { if (result.ok && flow.name === 'creating-credential') { - const finalConfig: AddGatewayTargetConfig = { - ...flow.pendingConfig, + const pending = flow.pendingConfig; + // Credential creation is only reachable from the mcpServer outbound-auth step + handleCreateComplete({ + targetType: 'mcpServer', + name: pending.name, + description: pending.description ?? `Tool for ${pending.name}`, + endpoint: pending.endpoint!, + gateway: pending.gateway!, + toolDefinition: pending.toolDefinition!, outboundAuth: { type: 'OAUTH', credentialName: result.result.name }, - }; - handleCreateComplete(finalConfig); + }); } else if (!result.ok) { setFlow({ name: 'error', message: result.error }); } @@ -158,10 +160,10 @@ export function AddGatewayTargetFlow({ { - resetCreate(); setFlow({ name: 'create-wizard' }); }} onExit={onExit} diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 7c891e5a..4637ddb1 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -1,11 +1,11 @@ -import type { GatewayTargetType } from '../../../../schema'; +import type { ApiGatewayHttpMethod, GatewayTargetType } from '../../../../schema'; import { ToolNameSchema } from '../../../../schema'; import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; import { HELP_TEXT } from '../../constants'; import { useListNavigation } from '../../hooks'; import { generateUniqueName } from '../../utils'; -import type { AddGatewayTargetConfig } from './types'; +import type { AddGatewayTargetConfig, GatewayTargetWizardState } from './types'; import { MCP_TOOL_STEP_LABELS, OUTBOUND_AUTH_OPTIONS, TARGET_TYPE_OPTIONS } from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; import { Box, Text } from 'ink'; @@ -16,7 +16,7 @@ interface AddGatewayTargetScreenProps { existingToolNames: string[]; existingOAuthCredentialNames: string[]; onComplete: (config: AddGatewayTargetConfig) => void; - onCreateCredential: (pendingConfig: AddGatewayTargetConfig) => void; + onCreateCredential: (pendingConfig: GatewayTargetWizardState) => void; onExit: () => void; } @@ -31,6 +31,7 @@ export function AddGatewayTargetScreen({ const wizard = useAddGatewayTargetWizard(existingGateways); const [outboundAuthType, setOutboundAuthTypeLocal] = useState(null); + const [filterPath, setFilterPathLocal] = useState(null); const gatewayItems: SelectableItem[] = useMemo( () => existingGateways.map(g => ({ id: g, title: g })), @@ -61,6 +62,9 @@ export function AddGatewayTargetScreen({ const isOutboundAuthStep = wizard.step === 'outbound-auth'; const isTargetTypeStep = wizard.step === 'target-type'; const isTextStep = wizard.step === 'name' || wizard.step === 'endpoint'; + const isRestApiIdStep = wizard.step === 'rest-api-id'; + const isStageStep = wizard.step === 'stage'; + const isToolFiltersStep = wizard.step === 'tool-filters'; const isConfirmStep = wizard.step === 'confirm'; const noGatewaysAvailable = isGatewayStep && existingGateways.length === 0; @@ -112,7 +116,29 @@ export function AddGatewayTargetScreen({ useListNavigation({ items: [{ id: 'confirm', title: 'Confirm' }], - onSelect: () => onComplete(wizard.config), + onSelect: () => { + const c = wizard.config; + if (c.targetType === 'apiGateway') { + onComplete({ + targetType: 'apiGateway', + name: c.name, + gateway: c.gateway!, + restApiId: c.restApiId!, + stage: c.stage!, + toolFilters: c.toolFilters, + }); + } else { + onComplete({ + targetType: 'mcpServer', + name: c.name, + description: c.description ?? `Tool for ${c.name}`, + endpoint: c.endpoint!, + gateway: c.gateway!, + toolDefinition: c.toolDefinition!, + outboundAuth: c.outboundAuth, + }); + } + }, onExit: () => { setOutboundAuthTypeLocal(null); wizard.goBack(); @@ -122,7 +148,7 @@ export function AddGatewayTargetScreen({ const helpText = isConfirmStep ? HELP_TEXT.CONFIRM_CANCEL - : isTextStep + : isTextStep || isRestApiIdStep || isStageStep || isToolFiltersStep ? HELP_TEXT.TEXT_INPUT : HELP_TEXT.NAVIGATE_SELECT; @@ -198,6 +224,65 @@ export function AddGatewayTargetScreen({ /> )} + {isRestApiIdStep && ( + wizard.goBack()} + /> + )} + + {isStageStep && ( + wizard.goBack()} + /> + )} + + {/* Tool filters uses a two-phase input within a single wizard step: + Phase 1: collect filter path pattern + Phase 2: collect HTTP methods for that path + Managed via local state (filterPath) rather than separate wizard steps + because it's a single logical step from the user's perspective. */} + {isToolFiltersStep && !filterPath && ( + setFilterPathLocal(value || '/*')} + onCancel={() => wizard.goBack()} + /> + )} + + {isToolFiltersStep && filterPath && ( + { + const methods = value + .split(',') + .map(m => m.trim().toUpperCase()) + .filter(Boolean) as ApiGatewayHttpMethod[]; + wizard.setToolFilters([{ filterPath, methods: methods.length > 0 ? methods : ['GET'] }]); + setFilterPathLocal(null); + }} + onCancel={() => setFilterPathLocal(null)} + customValidation={(value: string) => { + const methods = value + .split(',') + .map(m => m.trim().toUpperCase()) + .filter(Boolean); + if (methods.length === 0) return true; + const valid = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; + const invalid = methods.filter(m => !valid.includes(m)); + if (invalid.length > 0) return `Invalid method(s): ${invalid.join(', ')}. Valid: ${valid.join(', ')}`; + return true; + }} + /> + )} + {isConfirmStep && ( `${f.filterPath} ${f.methods.join(',')}`).join('; ') ?? '', + }, + ] + : []), + ...(wizard.config.targetType !== 'apiGateway' && wizard.config.endpoint + ? [{ label: 'Endpoint', value: wizard.config.endpoint }] + : []), { label: 'Gateway', value: wizard.config.gateway ?? '' }, - ...(wizard.config.outboundAuth + ...(wizard.config.targetType !== 'apiGateway' && wizard.config.outboundAuth ? [ { label: 'Auth Type', value: wizard.config.outboundAuth.type }, { label: 'Credential', value: wizard.config.outboundAuth.credentialName ?? 'None' }, diff --git a/src/cli/tui/screens/mcp/__tests__/discriminated-union.test.ts b/src/cli/tui/screens/mcp/__tests__/discriminated-union.test.ts new file mode 100644 index 00000000..bc9df1b0 --- /dev/null +++ b/src/cli/tui/screens/mcp/__tests__/discriminated-union.test.ts @@ -0,0 +1,105 @@ +import type { AddGatewayTargetConfig, ApiGatewayTargetConfig, McpServerTargetConfig } from '../types.js'; +import { describe, expect, it } from 'vitest'; + +describe('AddGatewayTargetConfig discriminated union', () => { + it('narrows to McpServerTargetConfig when targetType is mcpServer', () => { + const config: AddGatewayTargetConfig = { + targetType: 'mcpServer', + name: 'my-tool', + description: 'A tool', + endpoint: 'https://example.com/mcp', + gateway: 'my-gateway', + toolDefinition: { name: 'my-tool', description: 'A tool', inputSchema: { type: 'object' } }, + }; + + if (config.targetType === 'mcpServer') { + // TypeScript narrows — these are required fields, no ! needed + expect(config.endpoint).toBe('https://example.com/mcp'); + expect(config.description).toBe('A tool'); + expect(config.toolDefinition.name).toBe('my-tool'); + expect(config.gateway).toBe('my-gateway'); + } + }); + + it('narrows to ApiGatewayTargetConfig when targetType is apiGateway', () => { + const config: AddGatewayTargetConfig = { + targetType: 'apiGateway', + name: 'my-api', + gateway: 'my-gateway', + restApiId: 'abc123', + stage: 'prod', + toolFilters: [{ filterPath: '/*', methods: ['GET'] }], + }; + + if (config.targetType === 'apiGateway') { + expect(config.restApiId).toBe('abc123'); + expect(config.stage).toBe('prod'); + expect(config.gateway).toBe('my-gateway'); + } + }); + + it('McpServerTargetConfig requires all fields', () => { + const config: McpServerTargetConfig = { + targetType: 'mcpServer', + name: 'test', + description: 'desc', + endpoint: 'https://example.com', + gateway: 'gw', + toolDefinition: { name: 'test', description: 'desc', inputSchema: { type: 'object' } }, + }; + expect(config.targetType).toBe('mcpServer'); + expect(config.outboundAuth).toBeUndefined(); + }); + + it('ApiGatewayTargetConfig requires all fields', () => { + const config: ApiGatewayTargetConfig = { + targetType: 'apiGateway', + name: 'test', + gateway: 'gw', + restApiId: 'id', + stage: 'prod', + }; + expect(config.targetType).toBe('apiGateway'); + expect(config.toolFilters).toBeUndefined(); + }); + + it('McpServerTargetConfig accepts optional outboundAuth', () => { + const config: McpServerTargetConfig = { + targetType: 'mcpServer', + name: 'test', + description: 'desc', + endpoint: 'https://example.com', + gateway: 'gw', + toolDefinition: { name: 'test', description: 'desc', inputSchema: { type: 'object' } }, + outboundAuth: { type: 'OAUTH', credentialName: 'my-cred' }, + }; + expect(config.outboundAuth?.type).toBe('OAUTH'); + }); + + it('dispatches correctly based on targetType', () => { + const configs: AddGatewayTargetConfig[] = [ + { + targetType: 'mcpServer', + name: 'mcp', + description: 'd', + endpoint: 'https://e.com', + gateway: 'gw', + toolDefinition: { name: 'mcp', description: 'd', inputSchema: { type: 'object' } }, + }, + { + targetType: 'apiGateway', + name: 'apigw', + gateway: 'gw', + restApiId: 'id', + stage: 'prod', + }, + ]; + + const results = configs.map(c => { + if (c.targetType === 'mcpServer') return `mcp:${c.endpoint}`; + return `apigw:${c.restApiId}/${c.stage}`; + }); + + expect(results).toEqual(['mcp:https://e.com', 'apigw:id/prod']); + }); +}); diff --git a/src/cli/tui/screens/mcp/index.ts b/src/cli/tui/screens/mcp/index.ts index 4f7e44b1..c7909c9c 100644 --- a/src/cli/tui/screens/mcp/index.ts +++ b/src/cli/tui/screens/mcp/index.ts @@ -8,6 +8,9 @@ export type { AddGatewayConfig, AddGatewayStep, AddGatewayTargetConfig, + McpServerTargetConfig, + ApiGatewayTargetConfig, + GatewayTargetWizardState, AddGatewayTargetStep, ComputeHost, } from './types'; diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index d6c1a2d0..9a0c6a06 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -1,4 +1,5 @@ import type { + ApiGatewayHttpMethod, GatewayAuthorizerType, GatewayTargetType, NodeRuntime, @@ -60,26 +61,27 @@ export type AddGatewayTargetStep = | 'gateway' | 'host' | 'outbound-auth' + | 'rest-api-id' + | 'stage' + | 'tool-filters' | 'confirm'; export type TargetLanguage = 'Python' | 'TypeScript' | 'Other'; -export interface AddGatewayTargetConfig { +/** + * Wizard-internal state — all fields optional, built incrementally as the user + * progresses through wizard steps. Not used outside the wizard/screen boundary. + */ +export interface GatewayTargetWizardState { name: string; - description: string; - sourcePath: string; - language: TargetLanguage; - /** Target type selected by user */ + description?: string; + sourcePath?: string; + language?: TargetLanguage; targetType?: GatewayTargetType; - /** External endpoint URL */ endpoint?: string; - /** Gateway name */ gateway?: string; - /** Compute host (Lambda or AgentCoreRuntime) */ - host: ComputeHost; - /** Derived tool definition */ - toolDefinition: ToolDefinition; - /** Outbound auth configuration */ + host?: ComputeHost; + toolDefinition?: ToolDefinition; outboundAuth?: { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string; @@ -87,9 +89,39 @@ export interface AddGatewayTargetConfig { }; restApiId?: string; stage?: string; - toolFilters?: { filterPath: string; methods: string[] }[]; + toolFilters?: { filterPath: string; methods: ApiGatewayHttpMethod[] }[]; } +// ───────────────────────────────────────────────────────────────────────────── +// Discriminated union — fully-formed configs passed downstream of the wizard. +// Each variant has required fields for its target type. +// ───────────────────────────────────────────────────────────────────────────── + +export interface McpServerTargetConfig { + targetType: 'mcpServer'; + name: string; + description: string; + endpoint: string; + gateway: string; + toolDefinition: ToolDefinition; + outboundAuth?: { + type: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; + scopes?: string[]; + }; +} + +export interface ApiGatewayTargetConfig { + targetType: 'apiGateway'; + name: string; + gateway: string; + restApiId: string; + stage: string; + toolFilters?: { filterPath: string; methods: ApiGatewayHttpMethod[] }[]; +} + +export type AddGatewayTargetConfig = McpServerTargetConfig | ApiGatewayTargetConfig; + export const MCP_TOOL_STEP_LABELS: Record = { name: 'Name', 'target-type': 'Target Type', @@ -98,6 +130,9 @@ export const MCP_TOOL_STEP_LABELS: Record = { gateway: 'Gateway', host: 'Host', 'outbound-auth': 'Outbound Auth', + 'rest-api-id': 'REST API ID', + stage: 'Stage', + 'tool-filters': 'Tool Filters', confirm: 'Confirm', }; @@ -115,6 +150,11 @@ export const SKIP_FOR_NOW = 'skip-for-now' as const; export const TARGET_TYPE_OPTIONS = [ { id: 'mcpServer', title: 'MCP Server endpoint', description: 'Connect to an existing MCP-compatible server' }, + { + id: 'apiGateway', + title: 'API Gateway REST API', + description: 'Connect to an existing Amazon API Gateway REST API', + }, ] 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 3295a2b1..7f55ca92 100644 --- a/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts +++ b/src/cli/tui/screens/mcp/useAddGatewayTargetWizard.ts @@ -1,6 +1,6 @@ import { APP_DIR, MCP_APP_SUBDIR } from '../../../../lib'; -import type { GatewayTargetType, ToolDefinition } from '../../../../schema'; -import type { AddGatewayTargetConfig, AddGatewayTargetStep } from './types'; +import type { ApiGatewayHttpMethod, GatewayTargetType, ToolDefinition } from '../../../../schema'; +import type { AddGatewayTargetStep, GatewayTargetWizardState } from './types'; import { useCallback, useMemo, useState } from 'react'; function deriveToolDefinition(name: string): ToolDefinition { @@ -11,7 +11,7 @@ function deriveToolDefinition(name: string): ToolDefinition { }; } -function getDefaultConfig(): AddGatewayTargetConfig { +function getDefaultConfig(): GatewayTargetWizardState { return { name: '', description: '', @@ -23,7 +23,7 @@ function getDefaultConfig(): AddGatewayTargetConfig { } export function useAddGatewayTargetWizard(existingGateways: string[] = []) { - const [config, setConfig] = useState(getDefaultConfig); + const [config, setConfig] = useState(getDefaultConfig); const [step, setStep] = useState('name'); // Dynamic steps — recomputes when targetType changes @@ -31,6 +31,9 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { const baseSteps: AddGatewayTargetStep[] = ['name', 'target-type']; if (config.targetType) { switch (config.targetType) { + case 'apiGateway': + baseSteps.push('rest-api-id', 'stage', 'tool-filters', 'gateway'); + break; case 'mcpServer': default: baseSteps.push('endpoint', 'gateway', 'outbound-auth'); @@ -43,39 +46,67 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { const currentIndex = steps.indexOf(step); + const goToNextStep = useCallback(() => { + const idx = steps.indexOf(step); + const next = steps[idx + 1]; + if (idx >= 0 && next) { + setStep(next); + } + }, [steps, step]); + const goBack = useCallback(() => { const prevStep = steps[currentIndex - 1]; if (prevStep) setStep(prevStep); }, [currentIndex, steps]); - const setName = useCallback((name: string) => { - setConfig(c => ({ - ...c, - name, - description: `Tool for ${name}`, - sourcePath: `${APP_DIR}/${MCP_APP_SUBDIR}/${name}`, - toolDefinition: deriveToolDefinition(name), - })); - setStep('target-type'); - }, []); + const setName = useCallback( + (name: string) => { + setConfig(c => ({ + ...c, + name, + description: `Tool for ${name}`, + sourcePath: `${APP_DIR}/${MCP_APP_SUBDIR}/${name}`, + toolDefinition: deriveToolDefinition(name), + })); + goToNextStep(); + }, + [goToNextStep] + ); const setTargetType = useCallback((targetType: GatewayTargetType) => { setConfig(c => ({ ...c, targetType })); - setStep('endpoint'); + // Cannot use goToNextStep() here — config.targetType is changing, which triggers + // useMemo to recompute steps, but goToNextStep captures the OLD steps via closure. + // Must explicitly set the first type-specific step. + switch (targetType) { + case 'apiGateway': + setStep('rest-api-id'); + break; + case 'mcpServer': + default: + setStep('endpoint'); + break; + } }, []); - const setEndpoint = useCallback((endpoint: string) => { - setConfig(c => ({ - ...c, - endpoint, - })); - setStep('gateway'); - }, []); + const setEndpoint = useCallback( + (endpoint: string) => { + setConfig(c => ({ + ...c, + endpoint, + })); + goToNextStep(); + }, + [goToNextStep] + ); - const setGateway = useCallback((gateway: string) => { - setConfig(c => ({ ...c, gateway })); - setStep('outbound-auth'); - }, []); + const setGateway = useCallback( + (gateway: string) => { + setConfig(c => ({ ...c, gateway })); + goToNextStep(); + }, + [goToNextStep] + ); const setOutboundAuth = useCallback( (outboundAuth: { type: 'OAUTH' | 'API_KEY' | 'NONE'; credentialName?: string }) => { @@ -83,9 +114,9 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { ...c, outboundAuth, })); - setStep('confirm'); + goToNextStep(); }, - [] + [goToNextStep] ); const reset = useCallback(() => { @@ -93,6 +124,30 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { setStep('name'); }, []); + const setRestApiId = useCallback( + (restApiId: string) => { + setConfig(c => ({ ...c, restApiId })); + goToNextStep(); + }, + [goToNextStep] + ); + + const setStage = useCallback( + (stage: string) => { + setConfig(c => ({ ...c, stage })); + goToNextStep(); + }, + [goToNextStep] + ); + + const setToolFilters = useCallback( + (toolFilters: { filterPath: string; methods: ApiGatewayHttpMethod[] }[]) => { + setConfig(c => ({ ...c, toolFilters })); + goToNextStep(); + }, + [goToNextStep] + ); + return { config, step, @@ -105,6 +160,9 @@ export function useAddGatewayTargetWizard(existingGateways: string[] = []) { setEndpoint, setGateway, setOutboundAuth, + setRestApiId, + setStage, + setToolFilters, reset, }; } diff --git a/src/schema/llm-compacted/mcp.ts b/src/schema/llm-compacted/mcp.ts index 7a65d6ca..aa17a131 100644 --- a/src/schema/llm-compacted/mcp.ts +++ b/src/schema/llm-compacted/mcp.ts @@ -142,7 +142,7 @@ interface IamPolicyDocument { // ENUMS // ───────────────────────────────────────────────────────────────────────────── -type GatewayTargetType = 'lambda' | 'mcpServer' | 'openApiSchema' | 'smithyModel'; +type GatewayTargetType = 'lambda' | 'mcpServer' | 'openApiSchema' | 'smithyModel' | 'apiGateway'; type PythonRuntime = 'PYTHON_3_10' | 'PYTHON_3_11' | 'PYTHON_3_12' | 'PYTHON_3_13'; type NodeRuntime = 'NODE_18' | 'NODE_20' | 'NODE_22'; type NetworkMode = 'PUBLIC' | 'PRIVATE'; diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index 1d2423fe..b0eed1b7 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -357,6 +357,13 @@ export const AgentCoreGatewayTargetSchema = z path: ['toolDefinitions'], }); } + if (data.outboundAuth && data.outboundAuth.type !== 'NONE') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'outboundAuth is not applicable for apiGateway target type', + path: ['outboundAuth'], + }); + } } if (data.targetType === 'mcpServer' && !data.compute && !data.endpoint) { ctx.addIssue({