Skip to content

Commit 3b1df62

Browse files
authored
feat: add API Gateway REST API as new gateway target type (#509)
* refactor: rename McpGateway references to Gateway * feat: add apiGateway Zod schemas and target validation * feat: add --type api-gateway CLI support with validation and createApiGatewayTarget * test: add apiGateway schema and validation tests
1 parent 96e6691 commit 3b1df62

File tree

9 files changed

+379
-11
lines changed

9 files changed

+379
-11
lines changed

src/cli/cloudformation/outputs.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ export function parseGatewayOutputs(
4545
const gatewayNames = Object.keys(gatewaySpecs);
4646
const gatewayIdMap = new Map(gatewayNames.map(name => [toPascalId(name), name]));
4747

48-
// Match patterns: Gateway{Name}{Type}Output or McpGateway{Name}{Type}Output
49-
const outputPattern = /^(?:Mcp)?Gateway(.+?)(Id|Arn|Url)Output/;
48+
// Match patterns: Gateway{Name}{Type}Output
49+
const outputPattern = /^Gateway(.+?)(Id|Arn|Url)Output/;
5050

5151
for (const [key, value] of Object.entries(outputs)) {
5252
const match = outputPattern.exec(key);

src/cli/commands/add/__tests__/validate.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,78 @@ describe('validate', () => {
555555
expect(result.error).toBe('--oauth-discovery-url must be a valid URL');
556556
});
557557

558+
it('accepts valid api-gateway options', async () => {
559+
const result = await validateAddGatewayTargetOptions({
560+
name: 'my-api',
561+
type: 'api-gateway',
562+
restApiId: 'abc123',
563+
stage: 'prod',
564+
gateway: 'my-gateway',
565+
});
566+
expect(result.valid).toBe(true);
567+
});
568+
569+
it('rejects api-gateway without --rest-api-id', async () => {
570+
const result = await validateAddGatewayTargetOptions({
571+
name: 'my-api',
572+
type: 'api-gateway',
573+
stage: 'prod',
574+
gateway: 'my-gateway',
575+
});
576+
expect(result.valid).toBe(false);
577+
expect(result.error).toContain('--rest-api-id is required');
578+
});
579+
580+
it('rejects api-gateway without --stage', async () => {
581+
const result = await validateAddGatewayTargetOptions({
582+
name: 'my-api',
583+
type: 'api-gateway',
584+
restApiId: 'abc123',
585+
gateway: 'my-gateway',
586+
});
587+
expect(result.valid).toBe(false);
588+
expect(result.error).toContain('--stage is required');
589+
});
590+
591+
it('rejects --endpoint for api-gateway type', async () => {
592+
const result = await validateAddGatewayTargetOptions({
593+
name: 'my-api',
594+
type: 'api-gateway',
595+
restApiId: 'abc123',
596+
stage: 'prod',
597+
gateway: 'my-gateway',
598+
endpoint: 'https://example.com',
599+
});
600+
expect(result.valid).toBe(false);
601+
expect(result.error).toContain('not applicable');
602+
});
603+
604+
it('rejects --host for api-gateway type', async () => {
605+
const result = await validateAddGatewayTargetOptions({
606+
name: 'my-api',
607+
type: 'api-gateway',
608+
restApiId: 'abc123',
609+
stage: 'prod',
610+
gateway: 'my-gateway',
611+
host: 'Lambda',
612+
});
613+
expect(result.valid).toBe(false);
614+
expect(result.error).toContain('not applicable');
615+
});
616+
617+
it('rejects --outbound-auth for api-gateway type', async () => {
618+
const result = await validateAddGatewayTargetOptions({
619+
name: 'my-api',
620+
type: 'api-gateway',
621+
restApiId: 'abc123',
622+
stage: 'prod',
623+
gateway: 'my-gateway',
624+
outboundAuthType: 'NONE',
625+
});
626+
expect(result.valid).toBe(false);
627+
expect(result.error).toContain('not applicable');
628+
});
629+
558630
it('rejects --host with mcp-server type', async () => {
559631
const options: AddGatewayTargetOptions = {
560632
name: 'test-tool',

src/cli/commands/add/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ export interface AddGatewayTargetOptions {
5959
oauthClientSecret?: string;
6060
oauthDiscoveryUrl?: string;
6161
oauthScopes?: string;
62+
restApiId?: string;
63+
stage?: string;
64+
toolFilterPath?: string;
65+
toolFilterMethods?: string;
6266
json?: boolean;
6367
}
6468

src/cli/commands/add/validate.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,13 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO
220220
}
221221

222222
if (!options.type) {
223-
return { valid: false, error: '--type is required. Valid options: mcp-server' };
223+
return { valid: false, error: '--type is required. Valid options: mcp-server, api-gateway' };
224224
}
225225

226-
const typeMap: Record<string, string> = { 'mcp-server': 'mcpServer' };
226+
const typeMap: Record<string, string> = { 'mcp-server': 'mcpServer', 'api-gateway': 'apiGateway' };
227227
const mappedType = typeMap[options.type];
228228
if (!mappedType) {
229-
return { valid: false, error: `Invalid type: ${options.type}. Valid options: mcp-server` };
229+
return { valid: false, error: `Invalid type: ${options.type}. Valid options: mcp-server, api-gateway` };
230230
}
231231
options.type = mappedType;
232232

@@ -309,6 +309,35 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO
309309
}
310310
}
311311

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+
312341
if (mappedType === 'mcpServer') {
313342
if (options.host) {
314343
return { valid: false, error: '--host is not applicable for MCP server targets' };

src/cli/operations/agent/generate/schema-mapper.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,9 @@ export function mapModelProviderToIdentityProviders(
179179
}
180180

181181
/**
182-
* Maps MCP gateways to gateway providers for template rendering.
182+
* Maps gateways to gateway providers for template rendering.
183183
*/
184-
async function mapMcpGatewaysToGatewayProviders(): Promise<GatewayProviderRenderConfig[]> {
184+
async function mapGatewaysToGatewayProviders(): Promise<GatewayProviderRenderConfig[]> {
185185
try {
186186
const configIO = new ConfigIO();
187187
if (!configIO.configExists('mcp')) {
@@ -229,7 +229,7 @@ export async function mapGenerateConfigToRenderConfig(
229229
config: GenerateConfig,
230230
identityProviders: IdentityProviderRenderConfig[]
231231
): Promise<AgentRenderConfig> {
232-
const gatewayProviders = await mapMcpGatewaysToGatewayProviders();
232+
const gatewayProviders = await mapGatewaysToGatewayProviders();
233233

234234
return {
235235
name: config.projectName,

src/cli/primitives/GatewayTargetPrimitive.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
236236
.description('Add a gateway target to the project')
237237
.option('--name <name>', 'Target name')
238238
.option('--description <desc>', 'Target description')
239-
.option('--type <type>', 'Target type (required): mcp-server')
239+
.option('--type <type>', 'Target type (required): mcp-server, api-gateway')
240240
.option('--endpoint <url>', 'MCP server endpoint URL')
241241
.option('--language <lang>', 'Language: Python, TypeScript, Other')
242242
.option('--gateway <name>', 'Gateway name')
@@ -247,6 +247,10 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
247247
.option('--oauth-client-secret <secret>', 'OAuth client secret (creates credential inline)')
248248
.option('--oauth-discovery-url <url>', 'OAuth discovery URL (creates credential inline)')
249249
.option('--oauth-scopes <scopes>', 'OAuth scopes, comma-separated')
250+
.option('--rest-api-id <id>', 'API Gateway REST API ID (required for api-gateway type)')
251+
.option('--stage <stage>', 'API Gateway deployment stage (required for api-gateway type)')
252+
.option('--tool-filter-path <path>', 'Tool filter path pattern, e.g. /pets/*')
253+
.option('--tool-filter-methods <methods>', 'Comma-separated HTTP methods, e.g. GET,POST')
250254
.option('--json', 'Output as JSON')
251255
.action(async (rawOptions: Record<string, string | boolean | undefined>) => {
252256
const cliOptions = rawOptions as unknown as CLIAddGatewayTargetOptions;
@@ -273,6 +277,42 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
273277
none: 'NONE',
274278
};
275279

280+
// Handle API Gateway targets (no code generation)
281+
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',
288+
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,
297+
toolFilters: cliOptions.toolFilterPath
298+
? [
299+
{
300+
filterPath: cliOptions.toolFilterPath,
301+
methods: cliOptions.toolFilterMethods?.split(',').map(m => m.trim()) ?? ['GET'],
302+
},
303+
]
304+
: undefined,
305+
};
306+
const result = await this.createApiGatewayTarget(config);
307+
const output = { success: true, toolName: result.toolName };
308+
if (cliOptions.json) {
309+
console.log(JSON.stringify(output));
310+
} else {
311+
console.log(`Added gateway target '${result.toolName}'`);
312+
}
313+
process.exit(0);
314+
}
315+
276316
// Handle MCP server targets (existing endpoint, no code generation)
277317
if (cliOptions.type === 'mcpServer' && cliOptions.endpoint) {
278318
const config: AddGatewayTargetConfig = {
@@ -450,6 +490,59 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
450490
return { toolName: config.name, projectPath: '' };
451491
}
452492

493+
/**
494+
* Create an API Gateway target that connects to an existing Amazon API Gateway REST API.
495+
* Unlike `add()` which scaffolds new code, this registers an existing REST API.
496+
*/
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+
508+
const mcpSpec: AgentCoreMcpSpec = this.configIO.configExists('mcp')
509+
? await this.configIO.readMcpSpec()
510+
: { agentCoreGateways: [] };
511+
512+
const gateway = mcpSpec.agentCoreGateways.find(g => g.name === config.gateway);
513+
if (!gateway) {
514+
throw new Error(`Gateway "${config.gateway}" not found.`);
515+
}
516+
517+
if (!gateway.targets) {
518+
gateway.targets = [];
519+
}
520+
521+
if (gateway.targets.some(t => t.name === config.name)) {
522+
throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`);
523+
}
524+
525+
const target: AgentCoreGatewayTarget = {
526+
name: config.name,
527+
targetType: 'apiGateway',
528+
apiGateway: {
529+
restApiId: config.restApiId,
530+
stage: config.stage,
531+
apiGatewayToolConfiguration: {
532+
toolFilters: (config.toolFilters ?? [{ filterPath: '/*', methods: ['GET'] }]) as {
533+
filterPath: string;
534+
methods: ('GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS')[];
535+
}[],
536+
},
537+
},
538+
};
539+
540+
gateway.targets.push(target);
541+
await this.configIO.writeMcpSpec(mcpSpec);
542+
543+
return { toolName: config.name };
544+
}
545+
453546
// ═══════════════════════════════════════════════════════════════════
454547
// Private helpers
455548
// ═══════════════════════════════════════════════════════════════════

src/cli/tui/screens/mcp/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ export interface AddGatewayTargetConfig {
8585
credentialName?: string;
8686
scopes?: string[];
8787
};
88+
restApiId?: string;
89+
stage?: string;
90+
toolFilters?: { filterPath: string; methods: string[] }[];
8891
}
8992

9093
export const MCP_TOOL_STEP_LABELS: Record<AddGatewayTargetStep, string> = {

0 commit comments

Comments
 (0)