diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index df083c31..86ec368f 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -45,8 +45,8 @@ export function parseGatewayOutputs( const gatewayNames = Object.keys(gatewaySpecs); const gatewayIdMap = new Map(gatewayNames.map(name => [toPascalId(name), name])); - // Match patterns: Gateway{Name}{Type}Output or McpGateway{Name}{Type}Output - const outputPattern = /^(?:Mcp)?Gateway(.+?)(Id|Arn|Url)Output/; + // Match patterns: Gateway{Name}{Type}Output + const outputPattern = /^Gateway(.+?)(Id|Arn|Url)Output/; for (const [key, value] of Object.entries(outputs)) { const match = outputPattern.exec(key); diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 4bf5f489..b4ad92c5 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -555,6 +555,78 @@ describe('validate', () => { expect(result.error).toBe('--oauth-discovery-url must be a valid URL'); }); + it('accepts valid api-gateway options', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-api', + type: 'api-gateway', + restApiId: 'abc123', + stage: 'prod', + gateway: 'my-gateway', + }); + expect(result.valid).toBe(true); + }); + + it('rejects api-gateway without --rest-api-id', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-api', + type: 'api-gateway', + stage: 'prod', + gateway: 'my-gateway', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--rest-api-id is required'); + }); + + it('rejects api-gateway without --stage', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-api', + type: 'api-gateway', + restApiId: 'abc123', + gateway: 'my-gateway', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--stage is required'); + }); + + it('rejects --endpoint for api-gateway type', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-api', + type: 'api-gateway', + restApiId: 'abc123', + stage: 'prod', + gateway: 'my-gateway', + endpoint: 'https://example.com', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not applicable'); + }); + + it('rejects --host for api-gateway type', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-api', + type: 'api-gateway', + restApiId: 'abc123', + stage: 'prod', + gateway: 'my-gateway', + host: 'Lambda', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not applicable'); + }); + + it('rejects --outbound-auth for api-gateway type', async () => { + const result = await validateAddGatewayTargetOptions({ + name: 'my-api', + type: 'api-gateway', + restApiId: 'abc123', + stage: 'prod', + gateway: 'my-gateway', + outboundAuthType: 'NONE', + }); + expect(result.valid).toBe(false); + expect(result.error).toContain('not applicable'); + }); + it('rejects --host with mcp-server type', async () => { const options: AddGatewayTargetOptions = { name: 'test-tool', diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 595bc10e..ec28cc8c 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -59,6 +59,10 @@ export interface AddGatewayTargetOptions { oauthClientSecret?: string; oauthDiscoveryUrl?: string; oauthScopes?: string; + restApiId?: string; + stage?: string; + toolFilterPath?: string; + toolFilterMethods?: string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 3c02f38c..4d78619d 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -220,13 +220,13 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } if (!options.type) { - return { valid: false, error: '--type is required. Valid options: mcp-server' }; + return { valid: false, error: '--type is required. Valid options: mcp-server, api-gateway' }; } - const typeMap: Record = { 'mcp-server': 'mcpServer' }; + const typeMap: Record = { 'mcp-server': 'mcpServer', 'api-gateway': 'apiGateway' }; const mappedType = typeMap[options.type]; if (!mappedType) { - return { valid: false, error: `Invalid type: ${options.type}. Valid options: mcp-server` }; + return { valid: false, error: `Invalid type: ${options.type}. Valid options: mcp-server, api-gateway` }; } options.type = mappedType; @@ -309,6 +309,35 @@ 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/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 210d9a79..9cda6000 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -179,9 +179,9 @@ export function mapModelProviderToIdentityProviders( } /** - * Maps MCP gateways to gateway providers for template rendering. + * Maps gateways to gateway providers for template rendering. */ -async function mapMcpGatewaysToGatewayProviders(): Promise { +async function mapGatewaysToGatewayProviders(): Promise { try { const configIO = new ConfigIO(); if (!configIO.configExists('mcp')) { @@ -229,7 +229,7 @@ export async function mapGenerateConfigToRenderConfig( config: GenerateConfig, identityProviders: IdentityProviderRenderConfig[] ): Promise { - const gatewayProviders = await mapMcpGatewaysToGatewayProviders(); + const gatewayProviders = await mapGatewaysToGatewayProviders(); return { name: config.projectName, diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index 27c432fc..b3d71570 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -236,7 +236,7 @@ export class GatewayTargetPrimitive extends BasePrimitive', 'Target name') .option('--description ', 'Target description') - .option('--type ', 'Target type (required): mcp-server') + .option('--type ', 'Target type (required): mcp-server, api-gateway') .option('--endpoint ', 'MCP server endpoint URL') .option('--language ', 'Language: Python, TypeScript, Other') .option('--gateway ', 'Gateway name') @@ -247,6 +247,10 @@ export class GatewayTargetPrimitive extends BasePrimitive', 'OAuth client secret (creates credential inline)') .option('--oauth-discovery-url ', 'OAuth discovery URL (creates credential inline)') .option('--oauth-scopes ', 'OAuth scopes, comma-separated') + .option('--rest-api-id ', 'API Gateway REST API ID (required for api-gateway type)') + .option('--stage ', 'API Gateway deployment stage (required for api-gateway type)') + .option('--tool-filter-path ', 'Tool filter path pattern, e.g. /pets/*') + .option('--tool-filter-methods ', 'Comma-separated HTTP methods, e.g. GET,POST') .option('--json', 'Output as JSON') .action(async (rawOptions: Record) => { const cliOptions = rawOptions as unknown as CLIAddGatewayTargetOptions; @@ -273,6 +277,42 @@ export class GatewayTargetPrimitive extends BasePrimitive m.trim()) ?? ['GET'], + }, + ] + : undefined, + }; + const result = await this.createApiGatewayTarget(config); + const output = { success: true, toolName: result.toolName }; + if (cliOptions.json) { + console.log(JSON.stringify(output)); + } else { + console.log(`Added gateway target '${result.toolName}'`); + } + process.exit(0); + } + // Handle MCP server targets (existing endpoint, no code generation) if (cliOptions.type === 'mcpServer' && cliOptions.endpoint) { const config: AddGatewayTargetConfig = { @@ -450,6 +490,59 @@ 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.'); + } + + const mcpSpec: AgentCoreMcpSpec = this.configIO.configExists('mcp') + ? await this.configIO.readMcpSpec() + : { agentCoreGateways: [] }; + + const gateway = mcpSpec.agentCoreGateways.find(g => g.name === config.gateway); + if (!gateway) { + throw new Error(`Gateway "${config.gateway}" not found.`); + } + + if (!gateway.targets) { + gateway.targets = []; + } + + if (gateway.targets.some(t => t.name === config.name)) { + throw new Error(`Target "${config.name}" already exists in gateway "${gateway.name}".`); + } + + const target: AgentCoreGatewayTarget = { + name: config.name, + targetType: 'apiGateway', + apiGateway: { + restApiId: config.restApiId, + stage: config.stage, + apiGatewayToolConfiguration: { + toolFilters: (config.toolFilters ?? [{ filterPath: '/*', methods: ['GET'] }]) as { + filterPath: string; + methods: ('GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS')[]; + }[], + }, + }, + }; + + gateway.targets.push(target); + await this.configIO.writeMcpSpec(mcpSpec); + + return { toolName: config.name }; + } + // ═══════════════════════════════════════════════════════════════════ // Private helpers // ═══════════════════════════════════════════════════════════════════ diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 2f176b80..d6c1a2d0 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -85,6 +85,9 @@ export interface AddGatewayTargetConfig { credentialName?: string; scopes?: string[]; }; + restApiId?: string; + stage?: string; + toolFilters?: { filterPath: string; methods: string[] }[]; } export const MCP_TOOL_STEP_LABELS: Record = { diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 8c95c268..9bee05f0 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -3,6 +3,7 @@ import { AgentCoreGatewayTargetSchema, AgentCoreMcpRuntimeToolSchema, AgentCoreMcpSpecSchema, + ApiGatewayConfigSchema, CustomJwtAuthorizerConfigSchema, GatewayAuthorizerTypeSchema, GatewayTargetTypeSchema, @@ -14,7 +15,7 @@ import { import { describe, expect, it } from 'vitest'; describe('GatewayTargetTypeSchema', () => { - it.each(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel'])('accepts "%s"', type => { + it.each(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel', 'apiGateway'])('accepts "%s"', type => { expect(GatewayTargetTypeSchema.safeParse(type).success).toBe(true); }); @@ -390,6 +391,101 @@ describe('AgentCoreMcpRuntimeToolSchema', () => { }); }); +describe('ApiGatewayConfigSchema', () => { + it('accepts valid config', () => { + const result = ApiGatewayConfigSchema.safeParse({ + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: '/pets/*', methods: ['GET', 'POST'] }], + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects missing restApiId', () => { + const result = ApiGatewayConfigSchema.safeParse({ + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: '/*', methods: ['GET'] }], + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects empty toolFilters', () => { + const result = ApiGatewayConfigSchema.safeParse({ + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [], + }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('AgentCoreGatewayTargetSchema with apiGateway', () => { + it('accepts valid apiGateway target', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-api', + targetType: 'apiGateway', + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: '/pets/*', methods: ['GET', 'POST'] }], + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('rejects apiGateway without config', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-api', + targetType: 'apiGateway', + }); + expect(result.success).toBe(false); + }); + + it('rejects apiGateway with compute', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-api', + targetType: 'apiGateway', + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: '/*', methods: ['GET'] }], + }, + }, + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'x', handler: 'x' }, + pythonVersion: '3.13', + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects apiGateway with endpoint', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-api', + targetType: 'apiGateway', + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: '/*', methods: ['GET'] }], + }, + }, + endpoint: 'https://example.com', + }); + expect(result.success).toBe(false); + }); +}); + describe('AgentCoreGatewayTargetSchema with outbound auth', () => { const validToolDef = { name: 'myTool', diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index c5047bf1..1d2423fe 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; // MCP-Specific Schemas // ============================================================================ -export const GatewayTargetTypeSchema = z.enum(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel']); +export const GatewayTargetTypeSchema = z.enum(['lambda', 'mcpServer', 'openApiSchema', 'smithyModel', 'apiGateway']); export type GatewayTargetType = z.infer; // ============================================================================ @@ -71,6 +71,45 @@ export const OutboundAuthSchema = z export type OutboundAuth = z.infer; +// ============================================================================ +// API Gateway Target Schemas +// ============================================================================ + +export const ApiGatewayHttpMethodSchema = z.enum(['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']); +export type ApiGatewayHttpMethod = z.infer; + +export const ApiGatewayToolFilterSchema = z + .object({ + filterPath: z.string().min(1), + methods: z.array(ApiGatewayHttpMethodSchema).min(1), + }) + .strict(); + +export const ApiGatewayToolOverrideSchema = z + .object({ + name: z.string().min(1), + path: z.string().min(1), + method: ApiGatewayHttpMethodSchema, + description: z.string().optional(), + }) + .strict(); + +export const ApiGatewayToolConfigurationSchema = z + .object({ + toolFilters: z.array(ApiGatewayToolFilterSchema).min(1), + toolOverrides: z.array(ApiGatewayToolOverrideSchema).optional(), + }) + .strict(); + +export const ApiGatewayConfigSchema = z + .object({ + restApiId: z.string().min(1), + stage: z.string().min(1), + apiGatewayToolConfiguration: ApiGatewayToolConfigurationSchema, + }) + .strict(); +export type ApiGatewayConfig = z.infer; + export const McpImplLanguageSchema = z.enum(['TypeScript', 'Python']); export type McpImplementationLanguage = z.infer; @@ -284,9 +323,41 @@ export const AgentCoreGatewayTargetSchema = z endpoint: z.string().url().optional(), /** Outbound auth configuration for the target. */ outboundAuth: OutboundAuthSchema.optional(), + /** API Gateway configuration. Required for apiGateway target type. */ + apiGateway: ApiGatewayConfigSchema.optional(), }) .strict() .superRefine((data, ctx) => { + if (data.targetType === 'apiGateway') { + if (!data.apiGateway) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'apiGateway config is required for apiGateway target type', + path: ['apiGateway'], + }); + } + if (data.compute) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'compute is not applicable for apiGateway target type', + path: ['compute'], + }); + } + if (data.endpoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'endpoint is not applicable for apiGateway target type', + path: ['endpoint'], + }); + } + if (data.toolDefinitions && data.toolDefinitions.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'toolDefinitions is not applicable for apiGateway target type (tools are auto-discovered)', + path: ['toolDefinitions'], + }); + } + } if (data.targetType === 'mcpServer' && !data.compute && !data.endpoint) { ctx.addIssue({ code: z.ZodIssueCode.custom,