Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
406 changes: 405 additions & 1 deletion integ-tests/add-remove-gateway.test.ts

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/cli/commands/add/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface AddGatewayTargetOptions {
stage?: string;
toolFilterPath?: string;
toolFilterMethods?: string;
schema?: string;
schemaS3Account?: string;
json?: boolean;
}

Expand Down
58 changes: 55 additions & 3 deletions src/cli/commands/add/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
AddIdentityOptions,
AddMemoryOptions,
} from './types';
import { existsSync } from 'fs';
import { extname, resolve } from 'path';

export interface ValidationResult {
valid: boolean;
Expand Down Expand Up @@ -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<string, string> = { 'mcp-server': 'mcpServer', 'api-gateway': 'apiGateway' };
const typeMap: Record<string, string> = {
'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;

Expand Down Expand Up @@ -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' };
Expand Down
76 changes: 75 additions & 1 deletion src/cli/primitives/GatewayTargetPrimitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -252,6 +257,8 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
.option('--stage <stage>', 'API Gateway deployment stage (required for api-gateway type)')
.option('--tool-filter-path <path>', 'Tool filter path pattern, e.g. /pets/*')
.option('--tool-filter-methods <methods>', 'Comma-separated HTTP methods, e.g. GET,POST')
.option('--schema <path>', 'Path to schema file or S3 URI (for open-api-schema / smithy-model)')
.option('--schema-s3-account <id>', 'S3 bucket owner account ID (for cross-account access)')
.option('--json', 'Output as JSON')
.action(async (rawOptions: Record<string, string | boolean | undefined>) => {
const cliOptions = rawOptions as unknown as CLIAddGatewayTargetOptions;
Expand Down Expand Up @@ -318,6 +325,42 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
process.exit(0);
}

// Handle schema-based targets (OpenAPI / Smithy)
if ((cliOptions.type === 'openApiSchema' || cliOptions.type === 'smithyModel') && cliOptions.schema) {
const isS3 = cliOptions.schema.startsWith('s3://');
const schemaSource = isS3
? {
s3: {
uri: cliOptions.schema,
...(cliOptions.schemaS3Account ? { bucketOwnerAccountId: cliOptions.schemaS3Account } : {}),
},
}
: { inline: { path: cliOptions.schema } };

const config: SchemaBasedTargetConfig = {
name: cliOptions.name!,
targetType: cliOptions.type,
schemaSource,
gateway: cliOptions.gateway!,
...(cliOptions.outboundAuthType
? {
outboundAuth: {
type: outboundAuthMap[cliOptions.outboundAuthType.toLowerCase()] ?? 'NONE',
credentialName: cliOptions.credentialName,
},
}
: {}),
};
const result = await this.createSchemaBasedGatewayTarget(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: McpServerTargetConfig = {
Expand Down Expand Up @@ -527,6 +570,37 @@ export class GatewayTargetPrimitive extends BasePrimitive<AddGatewayTargetOption
return { toolName: config.name };
}

/**
* Create a schema-based gateway target (OpenAPI or Smithy).
* No code generation — tools are auto-derived from the schema by the service.
*/
async createSchemaBasedGatewayTarget(config: SchemaBasedTargetConfig): Promise<{ toolName: string }> {
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
// ═══════════════════════════════════════════════════════════════════
Expand Down
11 changes: 10 additions & 1 deletion src/cli/tui/screens/mcp/AddGatewayTargetFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
56 changes: 53 additions & 3 deletions src/cli/tui/screens/mcp/__tests__/discriminated-union.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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[] = [
{
Expand All @@ -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']);
});
});
1 change: 1 addition & 0 deletions src/cli/tui/screens/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
AddGatewayTargetConfig,
McpServerTargetConfig,
ApiGatewayTargetConfig,
SchemaBasedTargetConfig,
GatewayTargetWizardState,
AddGatewayTargetStep,
ComputeHost,
Expand Down
19 changes: 18 additions & 1 deletion src/cli/tui/screens/mcp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
GatewayTargetType,
NodeRuntime,
PythonRuntime,
SchemaSource,
ToolDefinition,
} from '../../../../schema';

Expand Down Expand Up @@ -91,6 +92,8 @@ export interface GatewayTargetWizardState {
restApiId?: string;
stage?: string;
toolFilters?: { filterPath: string; methods: ApiGatewayHttpMethod[] }[];
/** Schema source for openApiSchema / smithyModel targets */
schemaSource?: SchemaSource;
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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<AddGatewayTargetStep, string> = {
name: 'Name',
Expand Down Expand Up @@ -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 = [
Expand Down
2 changes: 2 additions & 0 deletions src/schema/llm-compacted/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down
Loading
Loading