diff --git a/integ-tests/add-remove-gateway.test.ts b/integ-tests/add-remove-gateway.test.ts index 559b5d693..4f09c2849 100644 --- a/integ-tests/add-remove-gateway.test.ts +++ b/integ-tests/add-remove-gateway.test.ts @@ -1,6 +1,6 @@ import { createTestProject, runCLI } from '../src/test-utils/index.js'; import type { TestProject } from '../src/test-utils/index.js'; -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -91,3 +91,407 @@ describe('integration: add and remove gateway with external MCP server', () => { }); }); }); + +describe('integration: add and remove gateway with OpenAPI schema target', () => { + let project: TestProject; + const gatewayName = 'OpenApiGateway'; + const targetName = 'PetstoreApi'; + const schemaFileName = 'petstore.json'; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + describe('openApiSchema lifecycle', () => { + it('adds a gateway', async () => { + const result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + }); + + it('adds an OpenAPI schema target with a local file', async () => { + // Write a minimal OpenAPI schema file in the project root + const schemaContent = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0.0' }, + paths: { '/pets': { get: { summary: 'List pets', operationId: 'listPets' } } }, + }); + await writeFile(join(project.projectPath, schemaFileName), schemaContent, 'utf-8'); + + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + targetName, + '--type', + 'open-api-schema', + '--schema', + `./${schemaFileName}`, + '--gateway', + gatewayName, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.toolName).toBe(targetName); + + const mcpSpec = await readMcpConfig(project.projectPath); + const gateway = mcpSpec.agentCoreGateways?.find((g: { name: string }) => g.name === gatewayName); + const target = gateway?.targets?.find((t: { name: string }) => t.name === targetName); + expect(target, `Target "${targetName}" should be in gateway targets`).toBeTruthy(); + expect(target.targetType).toBe('openApiSchema'); + expect(target.schemaSource?.inline?.path).toBe(`./${schemaFileName}`); + }); + + it('rejects duplicate target name', async () => { + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + targetName, + '--type', + 'open-api-schema', + '--schema', + `./${schemaFileName}`, + '--gateway', + gatewayName, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('removes the OpenAPI schema target', async () => { + const result = await runCLI(['remove', 'gateway-target', '--name', targetName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const mcpSpec = await readMcpConfig(project.projectPath); + const gateway = mcpSpec.agentCoreGateways?.find((g: { name: string }) => g.name === gatewayName); + const targets = gateway?.targets ?? []; + const found = targets.find((t: { name: string }) => t.name === targetName); + expect(found, `Target "${targetName}" should be removed`).toBeFalsy(); + }); + + it('removes the gateway', async () => { + const result = await runCLI(['remove', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + }); + }); +}); + +describe('integration: add gateway with S3 URI schema target', () => { + let project: TestProject; + const gatewayName = 'S3SchemaGateway'; + const targetName = 'S3Petstore'; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + describe('S3 URI openApiSchema lifecycle', () => { + it('adds a gateway', async () => { + const result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + }); + + it('adds an OpenAPI schema target with an S3 URI', async () => { + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + targetName, + '--type', + 'open-api-schema', + '--schema', + 's3://my-bucket/specs/petstore.json', + '--gateway', + gatewayName, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.toolName).toBe(targetName); + + const mcpSpec = await readMcpConfig(project.projectPath); + const gateway = mcpSpec.agentCoreGateways?.find((g: { name: string }) => g.name === gatewayName); + const target = gateway?.targets?.find((t: { name: string }) => t.name === targetName); + expect(target, `Target "${targetName}" should be in gateway targets`).toBeTruthy(); + expect(target.targetType).toBe('openApiSchema'); + expect(target.schemaSource?.s3?.uri).toBe('s3://my-bucket/specs/petstore.json'); + }); + + it('removes the S3 schema target', async () => { + const result = await runCLI(['remove', 'gateway-target', '--name', targetName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + }); + }); +}); + +describe('integration: add gateway with S3 URI and bucketOwnerAccountId', () => { + let project: TestProject; + const gatewayName = 'CrossAccountGateway'; + const targetName = 'CrossAccountApi'; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('adds a gateway and target with --schema-s3-account', async () => { + await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + targetName, + '--type', + 'open-api-schema', + '--schema', + 's3://cross-account-bucket/spec.json', + '--schema-s3-account', + '123456789012', + '--gateway', + gatewayName, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const mcpSpec = await readMcpConfig(project.projectPath); + const gateway = mcpSpec.agentCoreGateways?.find((g: { name: string }) => g.name === gatewayName); + const target = gateway?.targets?.find((t: { name: string }) => t.name === targetName); + expect(target.schemaSource?.s3?.uri).toBe('s3://cross-account-bucket/spec.json'); + expect(target.schemaSource?.s3?.bucketOwnerAccountId).toBe('123456789012'); + }); +}); + +describe('integration: add gateway with Smithy model target', () => { + let project: TestProject; + const gatewayName = 'SmithyGateway'; + const targetName = 'SmithyService'; + const schemaFileName = 'service-model.json'; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + describe('smithyModel lifecycle', () => { + it('adds a gateway', async () => { + const result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + }); + + it('adds a Smithy model target with a local file', async () => { + const schemaContent = JSON.stringify({ + smithy: '2.0', + shapes: { 'example#MyService': { type: 'service', version: '2024-01-01' } }, + }); + await writeFile(join(project.projectPath, schemaFileName), schemaContent, 'utf-8'); + + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + targetName, + '--type', + 'smithy-model', + '--schema', + `./${schemaFileName}`, + '--gateway', + gatewayName, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.toolName).toBe(targetName); + + const mcpSpec = await readMcpConfig(project.projectPath); + const gateway = mcpSpec.agentCoreGateways?.find((g: { name: string }) => g.name === gatewayName); + const target = gateway?.targets?.find((t: { name: string }) => t.name === targetName); + expect(target, `Target "${targetName}" should be in gateway targets`).toBeTruthy(); + expect(target.targetType).toBe('smithyModel'); + expect(target.schemaSource?.inline?.path).toBe(`./${schemaFileName}`); + }); + + it('removes the Smithy model target', async () => { + const result = await runCLI(['remove', 'gateway-target', '--name', targetName, '--json'], project.projectPath); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const mcpSpec = await readMcpConfig(project.projectPath); + const gateway = mcpSpec.agentCoreGateways?.find((g: { name: string }) => g.name === gatewayName); + const targets = gateway?.targets ?? []; + const found = targets.find((t: { name: string }) => t.name === targetName); + expect(found, `Target "${targetName}" should be removed`).toBeFalsy(); + }); + }); +}); + +describe('integration: schema-based target validation errors', () => { + let project: TestProject; + const gatewayName = 'ValidationGateway'; + + beforeAll(async () => { + project = await createTestProject({ noAgent: true }); + await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], project.projectPath); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('rejects open-api-schema without --schema flag', async () => { + const result = await runCLI( + ['add', 'gateway-target', '--name', 'NoSchema', '--type', 'open-api-schema', '--gateway', gatewayName, '--json'], + project.projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects open-api-schema with non-existent local file', async () => { + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + 'BadFile', + '--type', + 'open-api-schema', + '--schema', + './does-not-exist.json', + '--gateway', + gatewayName, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects open-api-schema with non-JSON file', async () => { + await writeFile(join(project.projectPath, 'spec.yaml'), 'openapi: 3.0.0', 'utf-8'); + + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + 'YamlFile', + '--type', + 'open-api-schema', + '--schema', + './spec.yaml', + '--gateway', + gatewayName, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects --schema-s3-account with local file', async () => { + await writeFile(join(project.projectPath, 'local.json'), '{}', 'utf-8'); + + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + 'BadS3Account', + '--type', + 'open-api-schema', + '--schema', + './local.json', + '--schema-s3-account', + '123456789012', + '--gateway', + gatewayName, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); + + it('rejects open-api-schema with --endpoint flag', async () => { + const result = await runCLI( + [ + 'add', + 'gateway-target', + '--name', + 'WithEndpoint', + '--type', + 'open-api-schema', + '--schema', + 's3://bucket/spec.json', + '--endpoint', + 'https://example.com', + '--gateway', + gatewayName, + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/package-lock.json b/package-lock.json index cc0ee403a..1d075b7c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13204,8 +13204,10 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "extraneous": true, + "dev": true, "license": "ISC", + "optional": true, + "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index ec28cc8c1..17a9facc5 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -63,6 +63,8 @@ export interface AddGatewayTargetOptions { stage?: string; toolFilterPath?: string; toolFilterMethods?: string; + schema?: string; + schemaS3Account?: string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index a45f25ebc..0e524f77d 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -16,6 +16,8 @@ import type { AddIdentityOptions, AddMemoryOptions, } from './types'; +import { existsSync } from 'fs'; +import { extname, resolve } from 'path'; export interface ValidationResult { valid: boolean; @@ -220,13 +222,24 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } if (!options.type) { - return { valid: false, error: '--type is required. Valid options: mcp-server, api-gateway' }; + return { + valid: false, + error: '--type is required. Valid options: mcp-server, api-gateway, open-api-schema, smithy-model', + }; } - const typeMap: Record = { 'mcp-server': 'mcpServer', 'api-gateway': 'apiGateway' }; + const typeMap: Record = { + 'mcp-server': 'mcpServer', + 'api-gateway': 'apiGateway', + 'open-api-schema': 'openApiSchema', + 'smithy-model': 'smithyModel', + }; const mappedType = typeMap[options.type]; if (!mappedType) { - return { valid: false, error: `Invalid type: ${options.type}. Valid options: mcp-server, api-gateway` }; + return { + valid: false, + error: `Invalid type: ${options.type}. Valid options: mcp-server, api-gateway, open-api-schema, smithy-model`, + }; } options.type = mappedType; @@ -342,6 +355,45 @@ export async function validateAddGatewayTargetOptions(options: AddGatewayTargetO } } + // Schema-based targets (OpenAPI / Smithy) + if (mappedType === 'openApiSchema' || mappedType === 'smithyModel') { + if (!options.schema) { + return { valid: false, error: '--schema is required for schema-based target types' }; + } + if (options.endpoint) { + return { valid: false, error: `--endpoint is not applicable for ${mappedType} target type` }; + } + if (options.host) { + return { valid: false, error: `--host is not applicable for ${mappedType} target type` }; + } + + const isS3 = options.schema.startsWith('s3://'); + if (isS3) { + // Validate S3 URI format: s3://bucket/key + const s3Path = options.schema.slice(5); // strip 's3://' + if (!s3Path.includes('/') || s3Path.startsWith('/')) { + return { valid: false, error: 'Invalid S3 URI format. Expected: s3://bucket-name/key' }; + } + } else { + // Local file validation + const resolvedPath = resolve(options.schema); + if (!existsSync(resolvedPath)) { + return { valid: false, error: `Schema file not found: ${options.schema}` }; + } + const ext = extname(resolvedPath).toLowerCase(); + if (ext !== '.json') { + return { valid: false, error: `Schema file must be a JSON file (.json), got: ${ext}` }; + } + } + + if (options.schemaS3Account && !isS3) { + return { valid: false, error: '--schema-s3-account is only valid with S3 URIs' }; + } + + 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 6d214407e..99e0a558c 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -14,7 +14,12 @@ 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 { ApiGatewayTargetConfig, GatewayTargetWizardState, McpServerTargetConfig } from '../tui/screens/mcp/types'; +import type { + ApiGatewayTargetConfig, + GatewayTargetWizardState, + McpServerTargetConfig, + SchemaBasedTargetConfig, +} 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'; @@ -252,6 +257,8 @@ export class GatewayTargetPrimitive extends BasePrimitive', '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('--schema ', 'Path to schema file or S3 URI (for open-api-schema / smithy-model)') + .option('--schema-s3-account ', 'S3 bucket owner account ID (for cross-account access)') .option('--json', 'Output as JSON') .action(async (rawOptions: Record) => { const cliOptions = rawOptions as unknown as CLIAddGatewayTargetOptions; @@ -318,6 +325,42 @@ export class GatewayTargetPrimitive extends BasePrimitive { + 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.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: config.targetType, + schemaSource: config.schemaSource, + ...(config.outboundAuth && { outboundAuth: config.outboundAuth }), + }; + + gateway.targets.push(target); + await this.configIO.writeMcpSpec(mcpSpec); + + return { toolName: config.name }; + } + // ═══════════════════════════════════════════════════════════════════ // Private helpers // ═══════════════════════════════════════════════════════════════════ diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx index 2e024e573..d6a6bafb4 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx @@ -75,7 +75,16 @@ export function AddGatewayTargetFlow({ .catch((err: unknown) => { setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unknown error' }); }); - } else { + } else if (config.targetType === 'openApiSchema' || config.targetType === 'smithyModel') { + void gatewayTargetPrimitive + .createSchemaBasedGatewayTarget(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' }); + }); + } else if (config.targetType === 'apiGateway') { void gatewayTargetPrimitive .createApiGatewayTarget(config) .then((result: { toolName: string }) => { diff --git a/src/cli/tui/screens/mcp/__tests__/discriminated-union.test.ts b/src/cli/tui/screens/mcp/__tests__/discriminated-union.test.ts index bc9df1b07..ba55ef122 100644 --- a/src/cli/tui/screens/mcp/__tests__/discriminated-union.test.ts +++ b/src/cli/tui/screens/mcp/__tests__/discriminated-union.test.ts @@ -1,4 +1,9 @@ -import type { AddGatewayTargetConfig, ApiGatewayTargetConfig, McpServerTargetConfig } from '../types.js'; +import type { + AddGatewayTargetConfig, + ApiGatewayTargetConfig, + McpServerTargetConfig, + SchemaBasedTargetConfig, +} from '../types.js'; import { describe, expect, it } from 'vitest'; describe('AddGatewayTargetConfig discriminated union', () => { @@ -76,6 +81,43 @@ describe('AddGatewayTargetConfig discriminated union', () => { expect(config.outboundAuth?.type).toBe('OAUTH'); }); + it('narrows to SchemaBasedTargetConfig when targetType is openApiSchema', () => { + const config: AddGatewayTargetConfig = { + targetType: 'openApiSchema', + name: 'petstore', + gateway: 'my-gateway', + schemaSource: { inline: { path: 'specs/petstore.json' } }, + }; + + if (config.targetType === 'openApiSchema' || config.targetType === 'smithyModel') { + expect(config.schemaSource).toEqual({ inline: { path: 'specs/petstore.json' } }); + expect(config.gateway).toBe('my-gateway'); + } + }); + + it('SchemaBasedTargetConfig requires all fields', () => { + const config: SchemaBasedTargetConfig = { + targetType: 'openApiSchema', + name: 'test', + gateway: 'gw', + schemaSource: { s3: { uri: 's3://bucket/key.json' } }, + }; + expect(config.targetType).toBe('openApiSchema'); + expect(config.outboundAuth).toBeUndefined(); + }); + + it('SchemaBasedTargetConfig accepts smithyModel', () => { + const config: SchemaBasedTargetConfig = { + targetType: 'smithyModel', + name: 'test', + gateway: 'gw', + schemaSource: { inline: { path: 'model.json' } }, + outboundAuth: { type: 'OAUTH', credentialName: 'my-cred' }, + }; + expect(config.targetType).toBe('smithyModel'); + expect(config.outboundAuth?.type).toBe('OAUTH'); + }); + it('dispatches correctly based on targetType', () => { const configs: AddGatewayTargetConfig[] = [ { @@ -93,13 +135,21 @@ describe('AddGatewayTargetConfig discriminated union', () => { restApiId: 'id', stage: 'prod', }, + { + targetType: 'openApiSchema', + name: 'openapi', + gateway: 'gw', + schemaSource: { inline: { path: 'spec.json' } }, + }, ]; const results = configs.map(c => { if (c.targetType === 'mcpServer') return `mcp:${c.endpoint}`; - return `apigw:${c.restApiId}/${c.stage}`; + if (c.targetType === 'openApiSchema' || c.targetType === 'smithyModel') return `schema:${c.name}`; + if (c.targetType === 'apiGateway') return `apigw:${c.restApiId}/${c.stage}`; + return `unknown:${c.name}`; }); - expect(results).toEqual(['mcp:https://e.com', 'apigw:id/prod']); + expect(results).toEqual(['mcp:https://e.com', 'apigw:id/prod', 'schema:openapi']); }); }); diff --git a/src/cli/tui/screens/mcp/index.ts b/src/cli/tui/screens/mcp/index.ts index c7909c9c1..e1aad2fa7 100644 --- a/src/cli/tui/screens/mcp/index.ts +++ b/src/cli/tui/screens/mcp/index.ts @@ -10,6 +10,7 @@ export type { AddGatewayTargetConfig, McpServerTargetConfig, ApiGatewayTargetConfig, + SchemaBasedTargetConfig, GatewayTargetWizardState, AddGatewayTargetStep, ComputeHost, diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 8eb675ff9..2a9b69b5a 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -4,6 +4,7 @@ import type { GatewayTargetType, NodeRuntime, PythonRuntime, + SchemaSource, ToolDefinition, } from '../../../../schema'; @@ -91,6 +92,8 @@ export interface GatewayTargetWizardState { restApiId?: string; stage?: string; toolFilters?: { filterPath: string; methods: ApiGatewayHttpMethod[] }[]; + /** Schema source for openApiSchema / smithyModel targets */ + schemaSource?: SchemaSource; } // ───────────────────────────────────────────────────────────────────────────── @@ -125,7 +128,19 @@ export interface ApiGatewayTargetConfig { }; } -export type AddGatewayTargetConfig = McpServerTargetConfig | ApiGatewayTargetConfig; +export interface SchemaBasedTargetConfig { + targetType: 'openApiSchema' | 'smithyModel'; + name: string; + gateway: string; + schemaSource: SchemaSource; + outboundAuth?: { + type: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; + scopes?: string[]; + }; +} + +export type AddGatewayTargetConfig = McpServerTargetConfig | ApiGatewayTargetConfig | SchemaBasedTargetConfig; export const MCP_TOOL_STEP_LABELS: Record = { name: 'Name', @@ -161,6 +176,8 @@ export const TARGET_TYPE_OPTIONS = [ title: 'API Gateway REST API', description: 'Connect to an existing Amazon API Gateway REST API', }, + { id: 'openApiSchema', title: 'OpenAPI Schema', description: 'Auto-derive tools from an OpenAPI JSON spec' }, + { id: 'smithyModel', title: 'Smithy Model', description: 'Auto-derive tools from a Smithy JSON model' }, ] as const; export const TARGET_LANGUAGE_OPTIONS = [ diff --git a/src/schema/llm-compacted/mcp.ts b/src/schema/llm-compacted/mcp.ts index aa17a131d..656c72dbf 100644 --- a/src/schema/llm-compacted/mcp.ts +++ b/src/schema/llm-compacted/mcp.ts @@ -29,6 +29,8 @@ interface AgentCoreGatewayTarget { targetType: GatewayTargetType; toolDefinition: ToolDefinition; compute?: ToolComputeConfig; // Omit for external/abstract tools + /** Schema source for openApiSchema / smithyModel targets. */ + schemaSource?: { inline: { path: string } } | { s3: { uri: string; bucketOwnerAccountId?: string } }; } // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/schema/schemas/__tests__/mcp.test.ts b/src/schema/schemas/__tests__/mcp.test.ts index 9bee05f09..ebbab121c 100644 --- a/src/schema/schemas/__tests__/mcp.test.ts +++ b/src/schema/schemas/__tests__/mcp.test.ts @@ -9,6 +9,7 @@ import { GatewayTargetTypeSchema, McpImplLanguageSchema, RuntimeConfigSchema, + SchemaSourceSchema, ToolComputeConfigSchema, ToolImplementationBindingSchema, } from '../mcp.js'; @@ -486,6 +487,131 @@ describe('AgentCoreGatewayTargetSchema with apiGateway', () => { }); }); +describe('SchemaSourceSchema', () => { + it('accepts inline source', () => { + const result = SchemaSourceSchema.safeParse({ inline: { path: 'specs/petstore.json' } }); + expect(result.success).toBe(true); + }); + + it('accepts S3 source', () => { + const result = SchemaSourceSchema.safeParse({ s3: { uri: 's3://bucket/key.json' } }); + expect(result.success).toBe(true); + }); + + it('accepts S3 source with bucketOwnerAccountId', () => { + const result = SchemaSourceSchema.safeParse({ + s3: { uri: 's3://bucket/key.json', bucketOwnerAccountId: '123456789012' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects S3 source without s3:// prefix', () => { + const result = SchemaSourceSchema.safeParse({ s3: { uri: 'https://bucket/key.json' } }); + expect(result.success).toBe(false); + }); + + it('rejects empty inline path', () => { + const result = SchemaSourceSchema.safeParse({ inline: { path: '' } }); + expect(result.success).toBe(false); + }); + + it('rejects object with both inline and s3', () => { + const result = SchemaSourceSchema.safeParse({ + inline: { path: 'specs/petstore.json' }, + s3: { uri: 's3://bucket/key.json' }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('AgentCoreGatewayTargetSchema with openApiSchema/smithyModel', () => { + it('accepts openApiSchema with inline schemaSource', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'petstore', + targetType: 'openApiSchema', + schemaSource: { inline: { path: 'specs/petstore.json' } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts openApiSchema with S3 schemaSource', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'petstore', + targetType: 'openApiSchema', + schemaSource: { s3: { uri: 's3://my-bucket/specs/petstore.json' } }, + }); + expect(result.success).toBe(true); + }); + + it('accepts openApiSchema with S3 schemaSource and bucketOwnerAccountId', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'petstore', + targetType: 'openApiSchema', + schemaSource: { s3: { uri: 's3://my-bucket/specs/petstore.json', bucketOwnerAccountId: '123456789012' } }, + }); + expect(result.success).toBe(true); + }); + + it('rejects openApiSchema without schemaSource', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'petstore', + targetType: 'openApiSchema', + }); + expect(result.success).toBe(false); + }); + + it('accepts smithyModel with inline schemaSource', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-service', + targetType: 'smithyModel', + schemaSource: { inline: { path: 'models/service.json' } }, + }); + expect(result.success).toBe(true); + }); + + it('rejects smithyModel without schemaSource', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'my-service', + targetType: 'smithyModel', + }); + expect(result.success).toBe(false); + }); + + it('rejects openApiSchema with compute', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'petstore', + targetType: 'openApiSchema', + schemaSource: { inline: { path: 'specs/petstore.json' } }, + compute: { + host: 'Lambda', + implementation: { language: 'Python', path: 'tools', handler: 'h' }, + pythonVersion: 'PYTHON_3_12', + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects openApiSchema with endpoint', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'petstore', + targetType: 'openApiSchema', + schemaSource: { inline: { path: 'specs/petstore.json' } }, + endpoint: 'https://example.com', + }); + expect(result.success).toBe(false); + }); + + it('accepts openApiSchema with outbound auth', () => { + const result = AgentCoreGatewayTargetSchema.safeParse({ + name: 'petstore', + targetType: 'openApiSchema', + schemaSource: { inline: { path: 'specs/petstore.json' } }, + outboundAuth: { type: 'OAUTH', credentialName: 'my-cred' }, + }); + expect(result.success).toBe(true); + }); +}); + describe('AgentCoreGatewayTargetSchema with outbound auth', () => { const validToolDef = { name: 'myTool', diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index b2f322f1f..35e75f9b4 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -299,6 +299,32 @@ export const ToolComputeConfigSchema = z.discriminatedUnion('host', [ export type ToolComputeConfig = z.infer; +// ============================================================================ +// Schema Source (for OpenAPI / Smithy targets) +// ============================================================================ + +/** S3 reference for an API schema file. */ +const SchemaS3SourceSchema = z + .object({ + uri: z.string().min(1).startsWith('s3://'), + bucketOwnerAccountId: z.string().optional(), + }) + .strict(); + +/** Inline (local file) reference for an API schema file. Path is relative to project root. */ +const SchemaInlineSourceSchema = z + .object({ + path: z.string().min(1), + }) + .strict(); + +/** Schema source: either a local file path (read at synth time) or an S3 URI. */ +export const SchemaSourceSchema = z.union([ + z.object({ inline: SchemaInlineSourceSchema }).strict(), + z.object({ s3: SchemaS3SourceSchema }).strict(), +]); +export type SchemaSource = z.infer; + // ============================================================================ // Gateway Target // ============================================================================ @@ -325,6 +351,8 @@ export const AgentCoreGatewayTargetSchema = z outboundAuth: OutboundAuthSchema.optional(), /** API Gateway configuration. Required for apiGateway target type. */ apiGateway: ApiGatewayConfigSchema.optional(), + /** Schema source for openApiSchema / smithyModel targets. */ + schemaSource: SchemaSourceSchema.optional(), }) .strict() .superRefine((data, ctx) => { @@ -365,6 +393,43 @@ export const AgentCoreGatewayTargetSchema = z }); } } + if (data.targetType === 'openApiSchema' || data.targetType === 'smithyModel') { + if (!data.schemaSource) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${data.targetType} targets require a schemaSource.`, + path: ['schemaSource'], + }); + } + if (data.compute) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `compute is not applicable for ${data.targetType} target type`, + path: ['compute'], + }); + } + if (data.endpoint) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `endpoint is not applicable for ${data.targetType} target type`, + path: ['endpoint'], + }); + } + if (data.toolDefinitions && data.toolDefinitions.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `toolDefinitions is not applicable for ${data.targetType} target type`, + path: ['toolDefinitions'], + }); + } + if (data.apiGateway) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `apiGateway config is not applicable for ${data.targetType} target type`, + path: ['apiGateway'], + }); + } + } if (data.targetType === 'mcpServer' && !data.compute && !data.endpoint) { ctx.addIssue({ code: z.ZodIssueCode.custom,