Skip to content

Commit 9ecf0fa

Browse files
authored
feat: add API Gateway target TUI wizard and address review feedback (#511)
* feat: add apiGateway TUI wizard flow, ResourceGraph display, and split tool-filter input * refactor: address review feedback — optional config fields, outboundAuth validation, clean dispatch * fix: validation ordering, success screen for apiGateway, showDevOption, setTargetType comment * refactor: replace AddGatewayTargetConfig with discriminated union types Introduce McpServerTargetConfig and ApiGatewayTargetConfig as a discriminated union, replacing the single bag-of-optionals interface. The wizard uses GatewayTargetWizardState internally and narrows to the union at the confirm step boundary. - Remove all 8 non-null assertions (!) from GatewayTargetPrimitive and useCreateMcp by narrowing method signatures - Add HTTP method validation to tool-filters TUI input - Add explanatory comment on two-phase tool-filters local state - Remove dead code-scaffolding branch from handleCreateComplete
1 parent 315df61 commit 9ecf0fa

14 files changed

Lines changed: 505 additions & 228 deletions

File tree

src/cli/commands/add/validate.ts

Lines changed: 30 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,36 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO
263263
};
264264
}
265265

266+
// API Gateway targets: validate early and return (skip outbound auth validation)
267+
if (mappedType === 'apiGateway') {
268+
if (!options.restApiId) {
269+
return { valid: false, error: '--rest-api-id is required for api-gateway type' };
270+
}
271+
if (!options.stage) {
272+
return { valid: false, error: '--stage is required for api-gateway type' };
273+
}
274+
if (options.endpoint) {
275+
return { valid: false, error: '--endpoint is not applicable for api-gateway type' };
276+
}
277+
if (options.host) {
278+
return { valid: false, error: '--host is not applicable for api-gateway type' };
279+
}
280+
if (options.language && options.language !== 'Other') {
281+
return { valid: false, error: '--language is not applicable for api-gateway type' };
282+
}
283+
if (options.outboundAuthType) {
284+
return { valid: false, error: '--outbound-auth is not applicable for api-gateway type' };
285+
}
286+
if (options.credentialName) {
287+
return { valid: false, error: '--credential-name is not applicable for api-gateway type' };
288+
}
289+
if (options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl || options.oauthScopes) {
290+
return { valid: false, error: 'OAuth options are not applicable for api-gateway type' };
291+
}
292+
options.language = 'Other';
293+
return { valid: true };
294+
}
295+
266296
// Validate outbound auth configuration
267297
if (options.outboundAuthType && options.outboundAuthType !== 'NONE') {
268298
const hasInlineOAuth = !!(options.oauthClientId ?? options.oauthClientSecret ?? options.oauthDiscoveryUrl);
@@ -309,35 +339,6 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO
309339
}
310340
}
311341

312-
if (mappedType === 'apiGateway') {
313-
if (!options.restApiId) {
314-
return { valid: false, error: '--rest-api-id is required for api-gateway type' };
315-
}
316-
if (!options.stage) {
317-
return { valid: false, error: '--stage is required for api-gateway type' };
318-
}
319-
if (options.endpoint) {
320-
return { valid: false, error: '--endpoint is not applicable for api-gateway type' };
321-
}
322-
if (options.host) {
323-
return { valid: false, error: '--host is not applicable for api-gateway type' };
324-
}
325-
if (options.language && options.language !== 'Other') {
326-
return { valid: false, error: '--language is not applicable for api-gateway type' };
327-
}
328-
if (options.outboundAuthType) {
329-
return { valid: false, error: '--outbound-auth is not applicable for api-gateway type' };
330-
}
331-
if (options.credentialName) {
332-
return { valid: false, error: '--credential-name is not applicable for api-gateway type' };
333-
}
334-
if (options.oauthClientId || options.oauthClientSecret || options.oauthDiscoveryUrl || options.oauthScopes) {
335-
return { valid: false, error: 'OAuth options are not applicable for api-gateway type' };
336-
}
337-
options.language = 'Other';
338-
return { valid: true };
339-
}
340-
341342
if (mappedType === 'mcpServer') {
342343
if (options.host) {
343344
return { valid: false, error: '--host is not applicable for MCP server targets' };

src/cli/primitives/GatewayTargetPrimitive.ts

Lines changed: 27 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
AgentCoreCliMcpDefs,
44
AgentCoreGatewayTarget,
55
AgentCoreMcpSpec,
6+
ApiGatewayHttpMethod,
67
DirectoryPath,
78
FilePath,
89
} from '../../schema';
@@ -13,7 +14,7 @@ import { getErrorMessage } from '../errors';
1314
import type { RemovableGatewayTarget } from '../operations/remove/remove-gateway-target';
1415
import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types';
1516
import { getTemplateToolDefinitions, renderGatewayTargetTemplate } from '../templates/GatewayTargetRenderer';
16-
import type { AddGatewayTargetConfig } from '../tui/screens/mcp/types';
17+
import type { ApiGatewayTargetConfig, GatewayTargetWizardState, McpServerTargetConfig } from '../tui/screens/mcp/types';
1718
import { DEFAULT_HANDLER, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION } from '../tui/screens/mcp/types';
1819
import { BasePrimitive } from './BasePrimitive';
1920
import { SOURCE_CODE_NOTE } from './constants';
@@ -279,26 +280,19 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
279280

280281
// Handle API Gateway targets (no code generation)
281282
if (cliOptions.type === 'apiGateway') {
282-
const config: AddGatewayTargetConfig = {
283-
name: cliOptions.name!,
284-
description: cliOptions.description ?? `API Gateway target for ${cliOptions.name!}`,
285-
sourcePath: '',
286-
language: 'Other',
287-
host: 'AgentCoreRuntime',
283+
const config: ApiGatewayTargetConfig = {
288284
targetType: 'apiGateway',
289-
toolDefinition: {
290-
name: cliOptions.name!,
291-
description: cliOptions.description ?? `API Gateway target for ${cliOptions.name!}`,
292-
inputSchema: { type: 'object' },
293-
},
294-
gateway: cliOptions.gateway,
295-
restApiId: cliOptions.restApiId,
296-
stage: cliOptions.stage,
285+
name: cliOptions.name!,
286+
gateway: cliOptions.gateway!,
287+
restApiId: cliOptions.restApiId!,
288+
stage: cliOptions.stage!,
297289
toolFilters: cliOptions.toolFilterPath
298290
? [
299291
{
300292
filterPath: cliOptions.toolFilterPath,
301-
methods: cliOptions.toolFilterMethods?.split(',').map(m => m.trim()) ?? ['GET'],
293+
methods: (cliOptions.toolFilterMethods?.split(',').map(m => m.trim()) ?? [
294+
'GET',
295+
]) as ApiGatewayHttpMethod[],
302296
},
303297
]
304298
: undefined,
@@ -315,20 +309,17 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
315309

316310
// Handle MCP server targets (existing endpoint, no code generation)
317311
if (cliOptions.type === 'mcpServer' && cliOptions.endpoint) {
318-
const config: AddGatewayTargetConfig = {
312+
const config: McpServerTargetConfig = {
313+
targetType: 'mcpServer',
319314
name: cliOptions.name!,
320315
description: cliOptions.description ?? `Tool for ${cliOptions.name!}`,
321-
sourcePath: '',
322-
language: cliOptions.language ?? 'Other',
323-
host: 'AgentCoreRuntime',
324-
targetType: 'mcpServer',
316+
endpoint: cliOptions.endpoint,
317+
gateway: cliOptions.gateway!,
325318
toolDefinition: {
326319
name: cliOptions.name!,
327320
description: cliOptions.description ?? `Tool for ${cliOptions.name!}`,
328321
inputSchema: { type: 'object' },
329322
},
330-
gateway: cliOptions.gateway,
331-
endpoint: cliOptions.endpoint,
332323
...(cliOptions.outboundAuthType
333324
? {
334325
outboundAuth: {
@@ -448,20 +439,14 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
448439
* Create an external gateway target that connects to an existing MCP server endpoint.
449440
* Unlike `add()` which scaffolds new code, this registers an existing endpoint URL.
450441
*/
451-
async createExternalGatewayTarget(
452-
config: AddGatewayTargetConfig
453-
): Promise<{ toolName: string; projectPath: string }> {
454-
if (!config.endpoint) {
455-
throw new Error('Endpoint URL is required for external MCP server targets.');
456-
}
457-
442+
async createExternalGatewayTarget(config: McpServerTargetConfig): Promise<{ toolName: string; projectPath: string }> {
458443
const mcpSpec: AgentCoreMcpSpec = this.configIO.configExists('mcp')
459444
? await this.configIO.readMcpSpec()
460445
: { agentCoreGateways: [] };
461446

462447
const target: AgentCoreGatewayTarget = {
463448
name: config.name,
464-
targetType: config.targetType ?? 'mcpServer',
449+
targetType: 'mcpServer',
465450
endpoint: config.endpoint,
466451
toolDefinitions: [config.toolDefinition],
467452
...(config.outboundAuth && { outboundAuth: config.outboundAuth }),
@@ -494,17 +479,7 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
494479
* Create an API Gateway target that connects to an existing Amazon API Gateway REST API.
495480
* Unlike `add()` which scaffolds new code, this registers an existing REST API.
496481
*/
497-
async createApiGatewayTarget(config: AddGatewayTargetConfig): Promise<{ toolName: string }> {
498-
if (!config.restApiId) {
499-
throw new Error('REST API ID is required for API Gateway targets.');
500-
}
501-
if (!config.stage) {
502-
throw new Error('Stage is required for API Gateway targets.');
503-
}
504-
if (!config.gateway) {
505-
throw new Error('Gateway name is required.');
506-
}
507-
482+
async createApiGatewayTarget(config: ApiGatewayTargetConfig): Promise<{ toolName: string }> {
508483
const mcpSpec: AgentCoreMcpSpec = this.configIO.configExists('mcp')
509484
? await this.configIO.readMcpSpec()
510485
: { agentCoreGateways: [] };
@@ -529,10 +504,7 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
529504
restApiId: config.restApiId,
530505
stage: config.stage,
531506
apiGatewayToolConfiguration: {
532-
toolFilters: (config.toolFilters ?? [{ filterPath: '/*', methods: ['GET'] }]) as {
533-
filterPath: string;
534-
methods: ('GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS')[];
535-
}[],
507+
toolFilters: config.toolFilters ?? [{ filterPath: '/*', methods: ['GET'] }],
536508
},
537509
},
538510
};
@@ -547,7 +519,7 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
547519
// Private helpers
548520
// ═══════════════════════════════════════════════════════════════════
549521

550-
private buildGatewayTargetConfig(options: AddGatewayTargetOptions): AddGatewayTargetConfig {
522+
private buildGatewayTargetConfig(options: AddGatewayTargetOptions): GatewayTargetWizardState {
551523
const sourcePath = `${APP_DIR}/${MCP_APP_SUBDIR}/${options.name}`;
552524
const description = options.description ?? `Tool for ${options.name}`;
553525
return {
@@ -566,16 +538,16 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
566538
}
567539

568540
private async createToolFromWizard(
569-
config: AddGatewayTargetConfig
541+
config: GatewayTargetWizardState
570542
): Promise<{ mcpDefsPath: string; toolName: string; projectPath: string }> {
571-
this.validateGatewayTargetLanguage(config.language);
543+
this.validateGatewayTargetLanguage(config.language!);
572544

573545
const mcpSpec: AgentCoreMcpSpec = this.configIO.configExists('mcp')
574546
? await this.configIO.readMcpSpec()
575547
: { agentCoreGateways: [] };
576548

577549
const toolDefs =
578-
config.host === 'Lambda' ? getTemplateToolDefinitions(config.name, config.host) : [config.toolDefinition];
550+
config.host === 'Lambda' ? getTemplateToolDefinitions(config.name, config.host) : [config.toolDefinition!];
579551

580552
for (const toolDef of toolDefs) {
581553
ToolDefinitionSchema.parse(toolDef);
@@ -615,7 +587,7 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
615587
? {
616588
host: 'Lambda',
617589
implementation: {
618-
path: config.sourcePath,
590+
path: config.sourcePath!,
619591
language: config.language,
620592
handler: DEFAULT_HANDLER,
621593
},
@@ -626,7 +598,7 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
626598
: {
627599
host: 'AgentCoreRuntime',
628600
implementation: {
629-
path: config.sourcePath,
601+
path: config.sourcePath!,
630602
language: 'Python',
631603
handler: 'server.py:main',
632604
},
@@ -635,7 +607,7 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
635607
pythonVersion: DEFAULT_PYTHON_VERSION,
636608
name: config.name,
637609
entrypoint: 'server.py:main' as FilePath,
638-
codeLocation: config.sourcePath as DirectoryPath,
610+
codeLocation: config.sourcePath! as DirectoryPath,
639611
networkMode: 'PUBLIC',
640612
},
641613
},
@@ -663,10 +635,10 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
663635
// Render gateway target project template
664636
const configRoot = requireConfigRoot();
665637
const projectRoot = dirname(configRoot);
666-
const absoluteSourcePath = join(projectRoot, config.sourcePath);
638+
const absoluteSourcePath = join(projectRoot, config.sourcePath!);
667639
await renderGatewayTargetTemplate(config.name, absoluteSourcePath, config.language, config.host);
668640

669-
return { mcpDefsPath, toolName: config.name, projectPath: config.sourcePath };
641+
return { mcpDefsPath, toolName: config.name, projectPath: config.sourcePath! };
670642
}
671643

672644
private validateGatewayTargetLanguage(language: string): asserts language is 'Python' | 'TypeScript' | 'Other' {

src/cli/tui/components/ResourceGraph.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,13 @@ function ResourceRow({
9898
);
9999
}
100100

101+
export function getTargetDisplayText(target: AgentCoreGatewayTarget): string {
102+
if (target.targetType === 'mcpServer' && target.endpoint) return target.endpoint;
103+
if (target.targetType === 'apiGateway' && target.apiGateway)
104+
return `${target.apiGateway.restApiId}/${target.apiGateway.stage}`;
105+
return target.name;
106+
}
107+
101108
export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: ResourceGraphProps) {
102109
const allAgents = project.agents ?? [];
103110
const agents = agentName ? allAgents.filter(a => a.name === agentName) : allAgents;
@@ -244,13 +251,13 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res
244251
identifier={rsEntry?.identifier}
245252
/>
246253
{targets.map(target => {
247-
const displayText =
248-
target.targetType === 'mcpServer' && target.endpoint ? target.endpoint : target.name;
254+
const displayText = getTargetDisplayText(target);
249255
return (
250256
<Text key={target.name}>
251257
{' '}
252258
<Text color="cyan">{ICONS.tool}</Text> {displayText}
253-
{target.targetType === 'mcpServer' && target.endpoint && (
259+
{(target.targetType === 'apiGateway' ||
260+
(target.targetType === 'mcpServer' && target.endpoint)) && (
254261
<Text color="gray"> [{target.targetType}]</Text>
255262
)}
256263
</Text>
@@ -282,10 +289,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res
282289
<Box flexDirection="column">
283290
<SectionHeader>⚠ Unassigned Targets</SectionHeader>
284291
{unassignedTargets.map((target, idx) => {
285-
const displayText =
286-
target.targetType === 'mcpServer' && target.endpoint
287-
? target.endpoint
288-
: (target.name ?? `Target ${idx + 1}`);
292+
const displayText = getTargetDisplayText(target);
289293
return <ResourceRow key={idx} icon="⚠" color="yellow" name={displayText} detail={target.targetType} />;
290294
})}
291295
</Box>

src/cli/tui/components/__tests__/ResourceGraph.test.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { AgentCoreMcpSpec, AgentCoreProjectSpec } from '../../../../schema/index.js';
22
import type { ResourceStatusEntry } from '../../../commands/status/action.js';
3-
import { ResourceGraph } from '../ResourceGraph.js';
3+
import { ResourceGraph, getTargetDisplayText } from '../ResourceGraph.js';
44
import { render } from 'ink-testing-library';
55
import React from 'react';
66
import { describe, expect, it } from 'vitest';
@@ -326,3 +326,34 @@ describe('ResourceGraph', () => {
326326
});
327327
});
328328
});
329+
330+
describe('getTargetDisplayText', () => {
331+
it('returns endpoint for mcpServer with endpoint', () => {
332+
const target = { name: 'my-tool', targetType: 'mcpServer', endpoint: 'https://example.com/mcp' } as any;
333+
expect(getTargetDisplayText(target)).toBe('https://example.com/mcp');
334+
});
335+
336+
it('returns restApiId/stage for apiGateway', () => {
337+
const target = {
338+
name: 'my-api',
339+
targetType: 'apiGateway',
340+
apiGateway: { restApiId: 'abc123', stage: 'prod' },
341+
} as any;
342+
expect(getTargetDisplayText(target)).toBe('abc123/prod');
343+
});
344+
345+
it('returns name for mcpServer without endpoint', () => {
346+
const target = { name: 'my-tool', targetType: 'mcpServer' } as any;
347+
expect(getTargetDisplayText(target)).toBe('my-tool');
348+
});
349+
350+
it('returns name for unknown target type', () => {
351+
const target = { name: 'my-tool', targetType: 'lambda' } as any;
352+
expect(getTargetDisplayText(target)).toBe('my-tool');
353+
});
354+
355+
it('returns name for apiGateway without apiGateway config', () => {
356+
const target = { name: 'my-api', targetType: 'apiGateway' } as any;
357+
expect(getTargetDisplayText(target)).toBe('my-api');
358+
});
359+
});

src/cli/tui/hooks/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export { useExitHandler } from './useExitHandler';
66
export { useListNavigation } from './useListNavigation';
77
export { useMultiSelectNavigation } from './useMultiSelectNavigation';
88
export { useResponsive } from './useResponsive';
9-
export { useAvailableAgents, useCreateGateway, useCreateGatewayTarget, useExistingGateways } from './useCreateMcp';
9+
export { useAvailableAgents, useCreateGateway, useExistingGateways } from './useCreateMcp';
1010
export { useDevServer } from './useDevServer';
1111
export { useProject } from './useProject';
1212
export type { UseProjectResult, ProjectContext } from './useProject';

0 commit comments

Comments
 (0)