diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 1f1df80b..89ef1df5 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -36,6 +36,7 @@ import { AgentCoreStack } from '../lib/cdk-stack'; import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; import { App, type Environment } from 'aws-cdk-lib'; import * as path from 'path'; +import * as fs from 'fs'; function toEnvironment(target: AwsDeploymentTarget): Environment { return { @@ -56,6 +57,17 @@ async function main() { const spec = await configIO.readProjectSpec(); const targets = await configIO.readAWSDeploymentTargets(); + // Read MCP configuration if it exists + let mcpSpec; + let mcpDeployedState; + try { + mcpSpec = await configIO.readMcpSpec(); + const deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + mcpDeployedState = deployedState?.mcp; + } catch { + // MCP config is optional + } + if (targets.length === 0) { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } @@ -68,6 +80,8 @@ async function main() { new AgentCoreStack(app, stackName, { spec, + mcpSpec, + mcpDeployedState, env, description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`, tags: { @@ -203,7 +217,13 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/jest.config.js should `; exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts should match snapshot 1`] = ` -"import { AgentCoreApplication, type AgentCoreProjectSpec } from '@aws/agentcore-cdk'; +"import { + AgentCoreApplication, + AgentCoreMcp, + type AgentCoreProjectSpec, + type McpSpec, + type McpDeployedState, +} from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -212,6 +232,14 @@ export interface AgentCoreStackProps extends StackProps { * The AgentCore project specification containing agents, memories, and credentials. */ spec: AgentCoreProjectSpec; + /** + * The MCP specification containing gateways and servers. + */ + mcpSpec?: McpSpec; + /** + * The MCP deployed state. + */ + mcpDeployedState?: McpDeployedState; } /** @@ -227,13 +255,22 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec } = props; + const { spec, mcpSpec, mcpDeployedState } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { spec, }); + // Create AgentCoreMcp if there are gateways configured + if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { + new AgentCoreMcp(this, 'Mcp', { + spec: mcpSpec, + deployedState: mcpDeployedState, + application: this.application, + }); + } + // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 92caa1f1..e590b9f2 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -3,6 +3,7 @@ import { AgentCoreStack } from '../lib/cdk-stack'; import { ConfigIO, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; import { App, type Environment } from 'aws-cdk-lib'; import * as path from 'path'; +import * as fs from 'fs'; function toEnvironment(target: AwsDeploymentTarget): Environment { return { @@ -23,6 +24,17 @@ async function main() { const spec = await configIO.readProjectSpec(); const targets = await configIO.readAWSDeploymentTargets(); + // Read MCP configuration if it exists + let mcpSpec; + let mcpDeployedState; + try { + mcpSpec = await configIO.readMcpSpec(); + const deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + mcpDeployedState = deployedState?.mcp; + } catch { + // MCP config is optional + } + if (targets.length === 0) { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } @@ -35,6 +47,8 @@ async function main() { new AgentCoreStack(app, stackName, { spec, + mcpSpec, + mcpDeployedState, env, description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, tags: { diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 051ad235..fbff1465 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -1,4 +1,10 @@ -import { AgentCoreApplication, type AgentCoreProjectSpec } from '@aws/agentcore-cdk'; +import { + AgentCoreApplication, + AgentCoreMcp, + type AgentCoreProjectSpec, + type McpSpec, + type McpDeployedState, +} from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -7,6 +13,14 @@ export interface AgentCoreStackProps extends StackProps { * The AgentCore project specification containing agents, memories, and credentials. */ spec: AgentCoreProjectSpec; + /** + * The MCP specification containing gateways and servers. + */ + mcpSpec?: McpSpec; + /** + * The MCP deployed state. + */ + mcpDeployedState?: McpDeployedState; } /** @@ -22,13 +36,22 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec } = props; + const { spec, mcpSpec, mcpDeployedState } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { spec, }); + // Create AgentCoreMcp if there are gateways configured + if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { + new AgentCoreMcp(this, 'Mcp', { + spec: mcpSpec, + deployedState: mcpDeployedState, + application: this.application, + }); + } + // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', diff --git a/src/cli/cloudformation/__tests__/outputs-extended.test.ts b/src/cli/cloudformation/__tests__/outputs-extended.test.ts index d6a64226..be2672da 100644 --- a/src/cli/cloudformation/__tests__/outputs-extended.test.ts +++ b/src/cli/cloudformation/__tests__/outputs-extended.test.ts @@ -157,7 +157,7 @@ describe('buildDeployedState', () => { }, }; - const state = buildDeployedState('default', 'MyStack', agents); + const state = buildDeployedState('default', 'MyStack', agents, {}); expect(state.targets.default).toBeDefined(); expect(state.targets.default!.resources?.agents).toEqual(agents); expect(state.targets.default!.resources?.stackName).toBe('MyStack'); @@ -181,7 +181,7 @@ describe('buildDeployedState', () => { DevAgent: { runtimeId: 'rt-d', runtimeArn: 'arn:rt-d', roleArn: 'arn:role-d' }, }; - const state = buildDeployedState('dev', 'DevStack', devAgents, existing); + const state = buildDeployedState('dev', 'DevStack', devAgents, {}, existing); expect(state.targets.prod).toBeDefined(); expect(state.targets.dev).toBeDefined(); expect(state.targets.prod!.resources?.stackName).toBe('ProdStack'); @@ -197,22 +197,22 @@ describe('buildDeployedState', () => { }, }; - const state = buildDeployedState('default', 'NewStack', {}, existing); + const state = buildDeployedState('default', 'NewStack', {}, {}, existing); expect(state.targets.default!.resources?.stackName).toBe('NewStack'); }); it('includes identityKmsKeyArn when provided', () => { - const state = buildDeployedState('default', 'Stack', {}, undefined, 'arn:aws:kms:key'); + const state = buildDeployedState('default', 'Stack', {}, {}, undefined, 'arn:aws:kms:key'); expect(state.targets.default!.resources?.identityKmsKeyArn).toBe('arn:aws:kms:key'); }); it('omits identityKmsKeyArn when undefined', () => { - const state = buildDeployedState('default', 'Stack', {}); + const state = buildDeployedState('default', 'Stack', {}, {}); expect(state.targets.default!.resources?.identityKmsKeyArn).toBeUndefined(); }); it('handles empty agents record', () => { - const state = buildDeployedState('default', 'Stack', {}); + const state = buildDeployedState('default', 'Stack', {}, {}); expect(state.targets.default!.resources?.agents).toEqual({}); }); }); diff --git a/src/cli/cloudformation/__tests__/outputs.test.ts b/src/cli/cloudformation/__tests__/outputs.test.ts index 1589844d..ce85fdcf 100644 --- a/src/cli/cloudformation/__tests__/outputs.test.ts +++ b/src/cli/cloudformation/__tests__/outputs.test.ts @@ -15,6 +15,7 @@ describe('buildDeployedState', () => { 'default', 'TestStack', agents, + {}, undefined, 'arn:aws:kms:us-east-1:123456789012:key/abc-123' ); @@ -31,7 +32,7 @@ describe('buildDeployedState', () => { }, }; - const result = buildDeployedState('default', 'TestStack', agents); + const result = buildDeployedState('default', 'TestStack', agents, {}); expect(result.targets.default!.resources?.identityKmsKeyArn).toBeUndefined(); }); @@ -52,6 +53,7 @@ describe('buildDeployedState', () => { 'dev', 'DevStack', {}, + {}, existingState, 'arn:aws:kms:us-east-1:123456789012:key/dev-key' ); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 6b8624b5..d10aa154 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -26,6 +26,47 @@ export async function getStackOutputs(region: string, stackName: string): Promis return outputs; } +/** + * Parse stack outputs into deployed state for gateways. + * + * Output key pattern for gateways: + * Gateway{GatewayName}UrlOutput{Hash} + * + * Examples: + * - GatewayMyGatewayUrlOutput3E11FAB4 + */ +export function parseGatewayOutputs( + outputs: StackOutputs, + gatewaySpecs: Record +): Record { + const gateways: Record = {}; + + // Map PascalCase gateway names to original names for lookup + const gatewayNames = Object.keys(gatewaySpecs); + const gatewayIdMap = new Map(gatewayNames.map(name => [toPascalId(name), name])); + + // Match pattern: Gateway{GatewayName}UrlOutput + const outputPattern = /^Gateway(.+?)UrlOutput/; + + for (const [key, value] of Object.entries(outputs)) { + const match = outputPattern.exec(key); + if (!match) continue; + + const logicalGateway = match[1]; + if (!logicalGateway) continue; + + // Look up original gateway name from PascalCase version + const gatewayName = gatewayIdMap.get(logicalGateway) ?? logicalGateway; + + gateways[gatewayName] = { + gatewayId: gatewayName, + gatewayArn: value, + }; + } + + return gateways; +} + /** * Parse stack outputs into deployed state for agents. * @@ -132,6 +173,7 @@ export function buildDeployedState( targetName: string, stackName: string, agents: Record, + gateways: Record, existingState?: DeployedState, identityKmsKeyArn?: string ): DeployedState { @@ -143,6 +185,13 @@ export function buildDeployedState( }, }; + // Add MCP state if gateways exist + if (Object.keys(gateways).length > 0) { + targetState.resources!.mcp = { + gateways, + }; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 08ba8de5..1e14ef6a 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -237,7 +237,7 @@ describe('validate', () => { describe('validateAddGatewayTargetOptions', () => { // AC15: Required fields validated - it('returns error for missing required fields', () => { + it('returns error for missing required fields', async () => { const requiredFields: { field: keyof AddGatewayTargetOptions; error: string }[] = [ { field: 'name', error: '--name is required' }, { field: 'language', error: '--language is required' }, @@ -246,44 +246,49 @@ describe('validate', () => { for (const { field, error } of requiredFields) { const opts = { ...validGatewayTargetOptionsMcpRuntime, [field]: undefined }; - const result = validateAddGatewayTargetOptions(opts); + const result = await validateAddGatewayTargetOptions(opts); expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false); expect(result.error).toBe(error); } }); // AC16: Invalid values rejected - it('returns error for invalid values', () => { - let result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, language: 'Java' as any }); + it('returns error for invalid values', async () => { + let result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptionsMcpRuntime, + language: 'Java' as any, + }); expect(result.valid).toBe(false); expect(result.error?.includes('Invalid language')).toBeTruthy(); - result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, exposure: 'invalid' as any }); + result = await validateAddGatewayTargetOptions({ + ...validGatewayTargetOptionsMcpRuntime, + exposure: 'invalid' as any, + }); expect(result.valid).toBe(false); expect(result.error?.includes('Invalid exposure')).toBeTruthy(); }); // AC17: mcp-runtime exposure requires agents - it('returns error for mcp-runtime without agents', () => { - let result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: undefined }); + it('returns error for mcp-runtime without agents', async () => { + let result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: undefined }); expect(result.valid).toBe(false); expect(result.error).toBe('--agents is required for mcp-runtime exposure'); - result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: ',,,' }); + result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsMcpRuntime, agents: ',,,' }); expect(result.valid).toBe(false); expect(result.error).toBe('At least one agent is required'); }); - // AC18: behind-gateway exposure is disabled (coming soon) - it('returns coming soon error for behind-gateway exposure', () => { - const result = validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsBehindGateway }); - expect(result.valid).toBe(false); - expect(result.error).toContain('coming soon'); + // AC18: behind-gateway exposure is enabled + it('passes for valid behind-gateway options', async () => { + const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptionsBehindGateway }); + expect(result.valid).toBe(true); }); // AC19: Valid options pass - it('passes for valid mcp-runtime options', () => { - expect(validateAddGatewayTargetOptions(validGatewayTargetOptionsMcpRuntime)).toEqual({ valid: true }); + it('passes for valid mcp-runtime options', async () => { + expect(await validateAddGatewayTargetOptions(validGatewayTargetOptionsMcpRuntime)).toEqual({ valid: true }); }); }); diff --git a/src/cli/commands/add/actions.ts b/src/cli/commands/add/actions.ts index c6df1ff1..cf83d3e6 100644 --- a/src/cli/commands/add/actions.ts +++ b/src/cli/commands/add/actions.ts @@ -71,6 +71,8 @@ export interface ValidatedAddGatewayTargetOptions { agents?: string; gateway?: string; host?: 'Lambda' | 'AgentCoreRuntime'; + outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; } export interface ValidatedAddMemoryOptions { @@ -286,6 +288,16 @@ function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): Ad const sourcePath = `${APP_DIR}/${MCP_APP_SUBDIR}/${options.name}`; const description = options.description ?? `Tool for ${options.name}`; + + // Build outboundAuth configuration if provided + const outboundAuth = + options.outboundAuthType && options.outboundAuthType !== 'NONE' + ? { + type: options.outboundAuthType, + credentialName: options.credentialName, + } + : undefined; + return { name: options.name, description, @@ -306,6 +318,7 @@ function buildGatewayTargetConfig(options: ValidatedAddGatewayTargetOptions): Ad .filter(Boolean) : [], gateway: options.exposure === 'behind-gateway' ? options.gateway : undefined, + outboundAuth, }; } diff --git a/src/cli/commands/add/command.tsx b/src/cli/commands/add/command.tsx index 0b1e8abe..1e7a2170 100644 --- a/src/cli/commands/add/command.tsx +++ b/src/cli/commands/add/command.tsx @@ -64,8 +64,7 @@ async function handleAddAgentCLI(options: AddAgentOptions): Promise { process.exit(result.success ? 0 : 1); } -// Gateway disabled - rename to _handleAddGatewayCLI until feature is re-enabled -async function _handleAddGatewayCLI(options: AddGatewayOptions): Promise { +async function handleAddGatewayCLI(options: AddGatewayOptions): Promise { const validation = validateAddGatewayOptions(options); if (!validation.valid) { if (options.json) { @@ -97,9 +96,8 @@ async function _handleAddGatewayCLI(options: AddGatewayOptions): Promise { process.exit(result.success ? 0 : 1); } -// MCP Tool disabled - prefix with underscore until feature is re-enabled -async function _handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Promise { - const validation = validateAddGatewayTargetOptions(options); +async function handleAddGatewayTargetCLI(options: AddGatewayTargetOptions): Promise { + const validation = await validateAddGatewayTargetOptions(options); if (!validation.valid) { if (options.json) { console.log(JSON.stringify({ success: false, error: validation.error })); @@ -241,9 +239,9 @@ export function registerAdd(program: Command) { await handleAddAgentCLI(options as AddAgentOptions); }); - // Subcommand: add gateway (disabled - coming soon) + // Subcommand: add gateway addCmd - .command('gateway', { hidden: true }) + .command('gateway') .description('Add an MCP gateway to the project') .option('--name ', 'Gateway name') .option('--description ', 'Gateway description') @@ -253,14 +251,14 @@ export function registerAdd(program: Command) { .option('--allowed-clients ', 'Comma-separated allowed client IDs (required for CUSTOM_JWT)') .option('--agents ', 'Comma-separated agent names to attach gateway to') .option('--json', 'Output as JSON') - .action(() => { - console.error('AgentCore Gateway integration is coming soon.'); - process.exit(1); + .action(async options => { + requireProject(); + await handleAddGatewayCLI(options as AddGatewayOptions); }); - // Subcommand: add gateway-target (disabled - coming soon) + // Subcommand: add gateway-target addCmd - .command('gateway-target', { hidden: true }) + .command('gateway-target') .description('Add a gateway target to the project') .option('--name ', 'Tool name') .option('--description ', 'Tool description') @@ -270,9 +268,9 @@ export function registerAdd(program: Command) { .option('--gateway ', 'Gateway name (for behind-gateway)') .option('--host ', 'Compute host: Lambda or AgentCoreRuntime (for behind-gateway)') .option('--json', 'Output as JSON') - .action(() => { - console.error('MCP Tool integration is coming soon.'); - process.exit(1); + .action(async options => { + requireProject(); + await handleAddGatewayTargetCLI(options as AddGatewayTargetOptions); }); // Subcommand: add memory (v2: top-level resource) diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index ad002ff8..bdab405d 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -50,6 +50,8 @@ export interface AddGatewayTargetOptions { agents?: string; gateway?: string; host?: 'Lambda' | 'AgentCoreRuntime'; + outboundAuthType?: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; json?: boolean; } diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 037a6df1..2934b25d 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -1,3 +1,4 @@ +import { ConfigIO } from '../../../lib'; import { AgentNameSchema, BuildTypeSchema, @@ -25,6 +26,35 @@ const MEMORY_OPTIONS = ['none', 'shortTerm', 'longAndShortTerm'] as const; const OIDC_WELL_KNOWN_SUFFIX = '/.well-known/openid-configuration'; const VALID_STRATEGIES = ['SEMANTIC', 'SUMMARIZATION', 'USER_PREFERENCE']; +/** + * Validate that a credential name exists in the project spec. + */ +async function validateCredentialExists(credentialName: string): Promise { + try { + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + + const credentialExists = project.credentials.some(c => c.name === credentialName); + if (!credentialExists) { + const availableCredentials = project.credentials.map(c => c.name); + if (availableCredentials.length === 0) { + return { + valid: false, + error: `Credential "${credentialName}" not found. No credentials are configured. Add credentials using 'agentcore add identity'.`, + }; + } + return { + valid: false, + error: `Credential "${credentialName}" not found. Available credentials: ${availableCredentials.join(', ')}`, + }; + } + + return { valid: true }; + } catch { + return { valid: false, error: 'Failed to read project configuration' }; + } +} + // Agent validation export function validateAddAgentOptions(options: AddAgentOptions): ValidationResult { if (!options.name) { @@ -154,7 +184,7 @@ export function validateAddGatewayOptions(options: AddGatewayOptions): Validatio } // MCP Tool validation -export function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions): ValidationResult { +export async function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions): Promise { if (!options.name) { return { valid: false, error: '--name is required' }; } @@ -172,15 +202,7 @@ export function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions } if (options.exposure !== 'mcp-runtime' && options.exposure !== 'behind-gateway') { - return { valid: false, error: 'Invalid exposure. Use mcp-runtime' }; - } - - // Gateway feature is disabled - if (options.exposure === 'behind-gateway') { - return { - valid: false, - error: "Behind-gateway exposure is coming soon. Use 'mcp-runtime' exposure instead.", - }; + return { valid: false, error: "Invalid exposure. Use 'mcp-runtime' or 'behind-gateway'" }; } if (options.exposure === 'mcp-runtime') { @@ -196,6 +218,22 @@ export function validateAddGatewayTargetOptions(options: AddGatewayTargetOptions } } + // Validate outbound auth configuration + if (options.outboundAuthType && options.outboundAuthType !== 'NONE') { + if (!options.credentialName) { + return { + valid: false, + error: `--credential-name is required when outbound auth type is ${options.outboundAuthType}`, + }; + } + + // Validate that the credential exists + const credentialValidation = await validateCredentialExists(options.credentialName); + if (!credentialValidation.valid) { + return credentialValidation; + } + } + return { valid: true }; } diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 537f55b9..1b022c99 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -1,7 +1,7 @@ import { ConfigIO, SecureCredentials } from '../../../lib'; import { validateAwsCredentials } from '../../aws/account'; import { createSwitchableIoHost } from '../../cdk/toolkit-lib'; -import { buildDeployedState, getStackOutputs, parseAgentOutputs } from '../../cloudformation'; +import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOutputs } from '../../cloudformation'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; import { @@ -64,6 +64,15 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'error')?.error ?? 'Identity setup failed'; + const errorResult = identityResult.results.find(r => r.status === 'error'); + const errorMsg = + errorResult?.error && typeof errorResult.error === 'string' ? errorResult.error : 'Identity setup failed'; endStep('error', errorMsg); logger.finalize(false); return { success: false, error: errorMsg, logPath: logger.getRelativeLogPath() }; @@ -190,7 +201,9 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0; + const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS'; + startStep(deployStepName); // Enable verbose output for resource-level events if (switchableIoHost && options.onResourceEvent) { @@ -215,11 +228,12 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise a.name); + const agentNames = context.projectSpec.agents?.map(a => a.name) || []; const agents = parseAgentOutputs(outputs, agentNames, stackName); + + // Parse gateway outputs + const gatewaySpecs = + mcpSpec?.agentCoreGateways?.reduce( + (acc, gateway) => { + acc[gateway.name] = gateway; + return acc; + }, + {} as Record + ) ?? {}; + const gateways = parseGatewayOutputs(outputs, gatewaySpecs); + const existingState = await configIO.readDeployedState().catch(() => undefined); - const deployedState = buildDeployedState(target.name, stackName, agents, existingState, identityKmsKeyArn); + const deployedState = buildDeployedState( + target.name, + stackName, + agents, + gateways, + existingState, + identityKmsKeyArn + ); await configIO.writeDeployedState(deployedState); + + // Show gateway URLs if any were deployed + if (Object.keys(gateways).length > 0) { + const gatewayUrls = Object.entries(gateways) + .map(([name, gateway]) => `${name}: ${gateway.gatewayArn}`) + .join(', '); + logger.log(`Gateway URLs: ${gatewayUrls}`); + } + endStep('success'); logger.finalize(true); @@ -255,7 +297,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { registerResourceRemove(removeCommand, 'memory', 'memory', 'Remove a memory provider from the project'); registerResourceRemove(removeCommand, 'identity', 'identity', 'Remove an identity provider from the project'); - // MCP Tool disabled - replace with registerResourceRemove() call when enabling - removeCommand - .command('gateway-target', { hidden: true }) - .description('Remove an MCP tool from the project') - .option('--name ', 'Name of resource to remove') - .option('--force', 'Skip confirmation prompt') - .option('--json', 'Output as JSON') - .action(() => { - console.error('MCP Tool integration is coming soon.'); - process.exit(1); - }); + registerResourceRemove(removeCommand, 'gateway-target', 'gateway-target', 'Remove an MCP tool from the project'); - // Gateway disabled - replace with registerResourceRemove() call when enabling - removeCommand - .command('gateway', { hidden: true }) - .description('Remove a gateway from the project') - .option('--name ', 'Name of resource to remove') - .option('--force', 'Skip confirmation prompt') - .option('--json', 'Output as JSON') - .action(() => { - console.error('AgentCore Gateway integration is coming soon.'); - process.exit(1); - }); + registerResourceRemove(removeCommand, 'gateway', 'gateway', 'Remove a gateway from the project'); // IMPORTANT: Register the catch-all argument LAST. No subcommands should be registered after this point. removeCommand diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 8401f68f..61f7c048 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -73,14 +73,25 @@ export async function validateProject(): Promise { const projectSpec = await configIO.readProjectSpec(); const awsTargets = await configIO.readAWSDeploymentTargets(); - // Validate that at least one agent is defined, unless this is a teardown deploy. + // Validate that at least one agent or gateway is defined, unless this is a teardown deploy. // // Teardown detection: when agents is empty but deployed-state.json records existing // targets, the user has run `remove all` and wants to tear down AWS resources via deploy. // deployed-state.json is written by the CLI after every successful deploy, so it is a // reliable indicator of whether a CloudFormation stack exists for this project. let isTeardownDeploy = false; - if (!projectSpec.agents || projectSpec.agents.length === 0) { + const hasAgents = projectSpec.agents && projectSpec.agents.length > 0; + + // Check for gateways in mcp.json + let hasGateways = false; + try { + const mcpSpec = await configIO.readMcpSpec(); + hasGateways = mcpSpec.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; + } catch { + // No mcp.json or invalid — no gateways + } + + if (!hasAgents && !hasGateways) { let hasExistingStack = false; try { const deployedState = await configIO.readDeployedState(); @@ -90,7 +101,7 @@ export async function validateProject(): Promise { } if (!hasExistingStack) { throw new Error( - 'No agents defined in project. Add at least one agent with "agentcore add agent" before deploying.' + 'No agents or gateways defined in project. Add at least one agent with "agentcore add agent" or gateway with "agentcore add gateway" before deploying.' ); } isTeardownDeploy = true; @@ -116,7 +127,7 @@ export async function validateProject(): Promise { */ function validateRuntimeNames(projectSpec: AgentCoreProjectSpec): void { const projectName = projectSpec.name; - for (const agent of projectSpec.agents) { + for (const agent of projectSpec.agents || []) { const agentName = agent.name; if (agentName) { const combinedName = `${projectName}_${agentName}`; @@ -136,7 +147,7 @@ function validateRuntimeNames(projectSpec: AgentCoreProjectSpec): void { */ export function validateContainerAgents(projectSpec: AgentCoreProjectSpec, configRoot: string): void { const errors: string[] = []; - for (const agent of projectSpec.agents) { + for (const agent of projectSpec.agents || []) { if (agent.build === 'Container') { const codeLocation = resolveCodeLocation(agent.codeLocation, configRoot); const dockerfilePath = path.join(codeLocation, DOCKERFILE_NAME); diff --git a/src/cli/operations/mcp/create-mcp.ts b/src/cli/operations/mcp/create-mcp.ts index f246fc69..c4ef2607 100644 --- a/src/cli/operations/mcp/create-mcp.ts +++ b/src/cli/operations/mcp/create-mcp.ts @@ -177,12 +177,38 @@ function validateGatewayTargetLanguage(language: string): asserts language is 'P } } +/** + * Validate that a credential name exists in the project spec. + */ +async function validateCredentialName(credentialName: string): Promise { + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + + const credentialExists = project.credentials.some(c => c.name === credentialName); + if (!credentialExists) { + const availableCredentials = project.credentials.map(c => c.name); + if (availableCredentials.length === 0) { + throw new Error( + `Credential "${credentialName}" not found. No credentials are configured. Add credentials using 'agentcore add identity'.` + ); + } + throw new Error( + `Credential "${credentialName}" not found. Available credentials: ${availableCredentials.join(', ')}` + ); + } +} + /** * Create an MCP tool (MCP runtime or behind gateway). */ export async function createToolFromWizard(config: AddGatewayTargetConfig): Promise { validateGatewayTargetLanguage(config.language); + // Validate credential if outboundAuth is configured + if (config.outboundAuth?.credentialName) { + await validateCredentialName(config.outboundAuth.credentialName); + } + const configIO = new ConfigIO(); const mcpSpec: AgentCoreMcpSpec = configIO.configExists('mcp') ? await configIO.readMcpSpec() @@ -301,6 +327,7 @@ export async function createToolFromWizard(config: AddGatewayTargetConfig): Prom networkMode: 'PUBLIC', }, }, + ...(config.outboundAuth && { outboundAuth: config.outboundAuth }), }; gateway.targets.push(target); diff --git a/src/cli/operations/remove/remove-identity.ts b/src/cli/operations/remove/remove-identity.ts index 14ed3b4f..68c9e417 100644 --- a/src/cli/operations/remove/remove-identity.ts +++ b/src/cli/operations/remove/remove-identity.ts @@ -42,6 +42,30 @@ export async function previewRemoveCredential(credentialName: string): Promise 0) { + summary.push( + `Warning: Credential "${credentialName}" is referenced by gateway targets: ${referencingTargets.join(', ')}. Removing it may break these targets.` + ); + } + const schemaChanges: SchemaChange[] = []; const afterSpec = { @@ -71,6 +95,29 @@ export async function removeCredential(credentialName: string): Promise 0) { + console.warn( + `Warning: Credential "${credentialName}" is referenced by gateway targets: ${referencingTargets.join(', ')}. Removing it may break these targets.` + ); + } + project.credentials.splice(credentialIndex, 1); await configIO.writeProjectSpec(project); diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index a9772bae..4fe99a85 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -1,5 +1,6 @@ import type { AgentCoreDeployedState, + AgentCoreGatewayTarget, AgentCoreMcpRuntimeTool, AgentCoreMcpSpec, AgentCoreProjectSpec, @@ -23,7 +24,7 @@ export interface AgentStatusInfo { interface ResourceGraphProps { project: AgentCoreProjectSpec; - mcp?: AgentCoreMcpSpec; + mcp?: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] }; agentName?: string; agentStatuses?: Record; deployedAgents?: Record; @@ -92,13 +93,15 @@ export function ResourceGraph({ const credentials = project.credentials ?? []; const gateways = mcp?.agentCoreGateways ?? []; const mcpRuntimeTools = mcp?.mcpRuntimeTools ?? []; + const unassignedTargets = mcp?.unassignedTargets ?? []; const hasContent = agents.length > 0 || memories.length > 0 || credentials.length > 0 || gateways.length > 0 || - mcpRuntimeTools.length > 0; + mcpRuntimeTools.length > 0 || + unassignedTargets.length > 0; return ( @@ -173,12 +176,19 @@ export function ResourceGraph({ name={gateway.name} detail={tools.length > 0 ? `${tools.length} tools` : undefined} /> - {tools.map(tool => ( - - {' '} - {ICONS.tool} {tool.name} - - ))} + {targets.map(target => { + const displayText = + target.targetType === 'mcpServer' && target.endpoint ? target.endpoint : target.name; + return ( + + {' '} + {ICONS.tool} {displayText} + {target.targetType === 'mcpServer' && target.endpoint && ( + [{target.targetType}] + )} + + ); + })} ); })} @@ -200,6 +210,20 @@ export function ResourceGraph({ )} + {/* Unassigned Targets */} + {unassignedTargets.length > 0 && ( + + ⚠ Unassigned Targets + {unassignedTargets.map((target, idx) => { + const displayText = + target.targetType === 'mcpServer' && target.endpoint + ? target.endpoint + : (target.name ?? `Target ${idx + 1}`); + return ; + })} + + )} + {/* Empty state */} {!hasContent && {'\n'} No resources configured} diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx index 60c07003..bbdf6883 100644 --- a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx +++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx @@ -105,7 +105,7 @@ describe('ResourceGraph', () => { agentCoreGateways: [ { name: 'my-gateway', - targets: [{ toolDefinitions: [{ name: 'tool-a' }, { name: 'tool-b' }] }], + targets: [{ name: 'target-a', toolDefinitions: [{ name: 'tool-a' }, { name: 'tool-b' }] }], }, ], } as unknown as AgentCoreMcpSpec; @@ -115,7 +115,7 @@ describe('ResourceGraph', () => { expect(lastFrame()).toContain('Gateways'); expect(lastFrame()).toContain('my-gateway'); expect(lastFrame()).toContain('2 tools'); - expect(lastFrame()).toContain('tool-a'); + expect(lastFrame()).toContain('target-a'); }); it('renders MCP runtime tools', () => { diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index f0d90de4..2a35ff1e 100644 --- a/src/cli/tui/screens/add/AddScreen.tsx +++ b/src/cli/tui/screens/add/AddScreen.tsx @@ -6,8 +6,8 @@ const ADD_RESOURCES = [ { id: 'agent', title: 'Agent', description: 'New or existing agent code' }, { id: 'memory', title: 'Memory', description: 'Persistent context storage' }, { id: 'identity', title: 'Identity', description: 'API key credential providers' }, - { id: 'gateway', title: 'Gateway (coming soon)', description: 'Route and manage MCP tools', disabled: true }, - { id: 'gateway-target', title: 'MCP Tool (coming soon)', description: 'Extend agent capabilities', disabled: true }, + { id: 'gateway', title: 'Gateway', description: 'Route and manage MCP tools' }, + { id: 'gateway-target', title: 'MCP Tool', description: 'Extend agent capabilities' }, ] as const; export type AddResourceType = (typeof ADD_RESOURCES)[number]['id']; @@ -24,7 +24,7 @@ export function AddScreen({ onSelect, onExit, hasAgents }: AddScreenProps) { () => ADD_RESOURCES.map(r => ({ ...r, - disabled: ('disabled' in r && r.disabled) || ((r.id === 'memory' || r.id === 'identity') && !hasAgents), + disabled: Boolean('disabled' in r && r.disabled) || ((r.id === 'memory' || r.id === 'identity') && !hasAgents), description: (r.id === 'memory' || r.id === 'identity') && !hasAgents ? 'Add an agent first' : r.description, })), [hasAgents] diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index c88dd57d..eaf9b16b 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -129,7 +129,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState if (!ctx || !currentStackName || !target) return; const configIO = new ConfigIO(); - const agentNames = ctx.projectSpec.agents.map((a: { name: string }) => a.name); + const agentNames = ctx.projectSpec.agents?.map((a: { name: string }) => a.name) || []; // Try to get outputs from CDK stream first (immediate, no API call) let outputs = streamOutputsRef.current ?? {}; @@ -167,7 +167,14 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState setStackOutputs(outputs); const existingState = await configIO.readDeployedState().catch(() => undefined); - const deployedState = buildDeployedState(target.name, currentStackName, agents, existingState, identityKmsKeyArn); + const deployedState = buildDeployedState( + target.name, + currentStackName, + agents, + {}, + existingState, + identityKmsKeyArn + ); await configIO.writeDeployedState(deployedState); }, [context, stackNames, logger, identityKmsKeyArn]); diff --git a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx index 4577e4c8..0a6bba97 100644 --- a/src/cli/tui/screens/mcp/AddGatewayScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayScreen.tsx @@ -139,12 +139,19 @@ export function AddGatewayScreen({ onComplete, onExit, existingGateways, availab )} {isAuthorizerStep && ( - + + + {authorizerItems[authorizerNav.selectedIndex]?.id === 'NONE' && ( + + ⚠️ Warning: Gateway will be publicly accessible without authorization + + )} + )} {isJwtConfigStep && ( diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 6278bb9c..f3a5c45f 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -46,7 +46,7 @@ export function AddGatewayTargetScreen({ id: o.id, title: o.title, description: o.description, - disabled: 'disabled' in o ? o.disabled : undefined, + disabled: 'disabled' in o ? Boolean(o.disabled) : undefined, })), [] ); diff --git a/src/cli/tui/screens/mcp/types.ts b/src/cli/tui/screens/mcp/types.ts index 89b74bc5..3c781d32 100644 --- a/src/cli/tui/screens/mcp/types.ts +++ b/src/cli/tui/screens/mcp/types.ts @@ -65,6 +65,12 @@ export interface AddGatewayTargetConfig { toolDefinition: ToolDefinition; /** Agent names to attach (only when exposure = mcp-runtime) */ selectedAgents: string[]; + /** Outbound auth configuration */ + outboundAuth?: { + type: 'OAUTH' | 'API_KEY' | 'NONE'; + credentialName?: string; + scopes?: string[]; + }; } export const MCP_TOOL_STEP_LABELS: Record = { @@ -82,8 +88,9 @@ export const MCP_TOOL_STEP_LABELS: Record = { // ───────────────────────────────────────────────────────────────────────────── export const AUTHORIZER_TYPE_OPTIONS = [ - { id: 'NONE', title: 'None', description: 'No authorization required' }, + { id: 'AWS_IAM', title: 'AWS IAM', description: 'AWS Identity and Access Management authorization' }, { id: 'CUSTOM_JWT', title: 'Custom JWT', description: 'JWT-based authorization via OIDC provider' }, + { id: 'NONE', title: 'None', description: 'No authorization required — gateway is publicly accessible' }, ] as const; export const TARGET_LANGUAGE_OPTIONS = [ @@ -96,9 +103,8 @@ export const EXPOSURE_MODE_OPTIONS = [ { id: 'mcp-runtime', title: 'MCP Runtime', description: 'Deploy as AgentCore MCP Runtime (select agents to attach)' }, { id: 'behind-gateway', - title: 'Behind Gateway (coming soon)', + title: 'Behind Gateway', description: 'Route through AgentCore Gateway', - disabled: true, }, ] as const; diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index fe140276..b739ba66 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -6,8 +6,8 @@ const REMOVE_RESOURCES = [ { id: 'agent', title: 'Agent', description: 'Remove an agent from the project' }, { id: 'memory', title: 'Memory', description: 'Remove a memory provider' }, { id: 'identity', title: 'Identity', description: 'Remove an identity provider' }, - { id: 'gateway', title: 'Gateway (coming soon)', description: 'Remove an MCP gateway', disabled: true }, - { id: 'gateway-target', title: 'MCP Tool (coming soon)', description: 'Remove an MCP tool', disabled: true }, + { id: 'gateway', title: 'Gateway', description: 'Remove an MCP gateway' }, + { id: 'gateway-target', title: 'MCP Tool', description: 'Remove an MCP tool' }, { id: 'all', title: 'All', description: 'Reset entire agentcore project' }, ] as const; @@ -32,16 +32,14 @@ export function RemoveScreen({ onSelect, onExit, agentCount, - // Gateway disabled - prefix with underscore until feature is re-enabled - gatewayCount: _gatewayCount, - // MCP Tool disabled - prefix with underscore until feature is re-enabled - mcpToolCount: _mcpToolCount, + gatewayCount, + mcpToolCount, memoryCount, identityCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { return REMOVE_RESOURCES.map(r => { - let disabled = ('disabled' in r && r.disabled) || false; + let disabled = Boolean('disabled' in r && r.disabled); let description: string = r.description; switch (r.id) { @@ -51,6 +49,18 @@ export function RemoveScreen({ description = 'No agents to remove'; } break; + case 'gateway': + if (gatewayCount === 0) { + disabled = true; + description = 'No gateways to remove'; + } + break; + case 'gateway-target': + if (mcpToolCount === 0) { + disabled = true; + description = 'No gateway targets to remove'; + } + break; case 'memory': if (memoryCount === 0) { disabled = true; @@ -70,7 +80,7 @@ export function RemoveScreen({ return { ...r, disabled, description }; }); - }, [agentCount, memoryCount, identityCount]); + }, [agentCount, gatewayCount, mcpToolCount, memoryCount, identityCount]); const isDisabled = (item: SelectableItem) => item.disabled ?? false; diff --git a/src/cli/tui/screens/schema/McpGuidedEditor.tsx b/src/cli/tui/screens/schema/McpGuidedEditor.tsx index f8c0b74b..95977e9a 100644 --- a/src/cli/tui/screens/schema/McpGuidedEditor.tsx +++ b/src/cli/tui/screens/schema/McpGuidedEditor.tsx @@ -1,9 +1,11 @@ import { type AgentCoreGateway, + type AgentCoreGatewayTarget, type AgentCoreMcpRuntimeTool, type AgentCoreMcpSpec, AgentCoreMcpSpecSchema, GatewayNameSchema, + type OutboundAuth, } from '../../../../schema'; import { Header, Panel, ScreenLayout, TextInput } from '../../components'; import { useSchemaDocument } from '../../hooks/useSchemaDocument'; @@ -48,7 +50,10 @@ export function McpGuidedEditor(props: McpGuidedEditorProps) { ); } - let mcpSpec: AgentCoreMcpSpec = { agentCoreGateways: [] }; + let mcpSpec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] } = { + agentCoreGateways: [], + unassignedTargets: [], + }; try { const parsed: unknown = JSON.parse(content); const result = AgentCoreMcpSpecSchema.safeParse(parsed); @@ -79,7 +84,7 @@ type ScreenMode = 'list' | 'confirm-exit' | 'edit-item' | 'edit-field' | 'edit-t function McpEditorBody(props: { schema: SchemaOption; - initialSpec: AgentCoreMcpSpec; + initialSpec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] }; baseline: string; onBack: () => void; onSave: (content: string) => Promise<{ ok: boolean; error?: string }>; @@ -89,6 +94,9 @@ function McpEditorBody(props: { const [mcpRuntimeTools, setMcpRuntimeTools] = useState( props.initialSpec.mcpRuntimeTools ?? [] ); + const [unassignedTargets, _setUnassignedTargets] = useState( + props.initialSpec.unassignedTargets ?? [] + ); const [viewMode, setViewMode] = useState('gateways'); const [selectedIndex, setSelectedIndex] = useState(0); const [expandedIndex, setExpandedIndex] = useState(null); @@ -116,12 +124,19 @@ function McpEditorBody(props: { const currentFields = viewMode === 'gateways' ? gatewayFields : mcpRuntimeFields; // Target fields - const targetFields = [{ id: 'targetName', label: 'Target Name' }]; + const currentTarget = currentGateway?.targets?.[selectedTargetIndex]; + const targetFields = [ + { id: 'targetName', label: 'Target Name' }, + { id: 'targetType', label: 'Target Type' }, + ...(currentTarget?.targetType === 'mcpServer' ? [{ id: 'endpoint', label: 'Endpoint URL' }] : []), + { id: 'outboundAuth', label: 'Outbound Auth' }, + ]; async function commitChanges() { - const spec: AgentCoreMcpSpec = { + const spec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] } = { agentCoreGateways: gateways, ...(mcpRuntimeTools.length > 0 ? { mcpRuntimeTools: mcpRuntimeTools } : {}), + ...(unassignedTargets.length > 0 ? { unassignedTargets: unassignedTargets } : {}), }; const content = JSON.stringify(spec, null, 2); const result = await props.onSave(content); @@ -417,7 +432,9 @@ function McpEditorBody(props: { const selected = idx === selectedTargetIndex; const targetName = target.name ?? `Target ${idx + 1}`; const toolCount = target.toolDefinitions?.length ?? 0; - const host = target.compute?.host ?? target.targetType; + const targetType = target.targetType; + const endpoint = target.endpoint; + const displayInfo = endpoint ?? target.compute?.host ?? targetType; return ( {selected ? '❯' : ' '} @@ -425,7 +442,7 @@ function McpEditorBody(props: { {targetName} - ({toolCount} tools · {host}) + ({toolCount} tools · {targetType} · {displayInfo}) ); @@ -451,17 +468,42 @@ function McpEditorBody(props: { let initialValue = ''; if (editingTargetFieldId === 'targetName') { initialValue = target.name ?? ''; + } else if (editingTargetFieldId === 'targetType') { + initialValue = target.targetType ?? ''; + } else if (editingTargetFieldId === 'endpoint') { + initialValue = target.endpoint ?? ''; + } else if (editingTargetFieldId === 'outboundAuth') { + const auth = target.outboundAuth; + initialValue = auth ? `${auth.type}${auth.credentialName ? `:${auth.credentialName}` : ''}` : 'NONE'; } const handleSubmit = (value: string) => { if (viewMode === 'gateways' && gateway) { const updatedTargets = [...(gateway.targets ?? [])]; const targetToUpdate = updatedTargets[selectedTargetIndex]; - if (targetToUpdate && editingTargetFieldId === 'targetName') { - updatedTargets[selectedTargetIndex] = { - ...targetToUpdate, - name: value, - }; + if (targetToUpdate) { + if (editingTargetFieldId === 'targetName') { + updatedTargets[selectedTargetIndex] = { ...targetToUpdate, name: value }; + } else if (editingTargetFieldId === 'targetType') { + const validTypes = ['mcpServer', 'lambda', 'openApiSchema', 'smithyModel'] as const; + const targetType = validTypes.includes(value as (typeof validTypes)[number]) + ? (value as (typeof validTypes)[number]) + : targetToUpdate.targetType; + updatedTargets[selectedTargetIndex] = { ...targetToUpdate, targetType }; + } else if (editingTargetFieldId === 'endpoint') { + updatedTargets[selectedTargetIndex] = { ...targetToUpdate, endpoint: value || undefined }; + } else if (editingTargetFieldId === 'outboundAuth') { + const [type, credentialName] = value.split(':'); + const validAuthTypes = ['NONE', 'OAUTH', 'API_KEY'] as const; + const authType = validAuthTypes.includes(type as (typeof validAuthTypes)[number]) + ? (type as (typeof validAuthTypes)[number]) + : 'NONE'; + const outboundAuth: OutboundAuth = { + type: authType, + ...(credentialName ? { credentialName } : {}), + }; + updatedTargets[selectedTargetIndex] = { ...targetToUpdate, outboundAuth }; + } const next = gateways.map((g, idx) => (idx === selectedIndex ? { ...g, targets: updatedTargets } : g)); setGateways(next); setDirty(true); @@ -478,7 +520,17 @@ function McpEditorBody(props: { { setEditingTargetFieldId(null); @@ -492,9 +544,10 @@ function McpEditorBody(props: { // Confirm exit screen if (screenMode === 'confirm-exit') { - const spec: AgentCoreMcpSpec = { + const spec: AgentCoreMcpSpec & { unassignedTargets?: AgentCoreGatewayTarget[] } = { agentCoreGateways: gateways, ...(mcpRuntimeTools.length > 0 ? { mcpRuntimeTools: mcpRuntimeTools } : {}), + ...(unassignedTargets.length > 0 ? { unassignedTargets: unassignedTargets } : {}), }; const currentText = JSON.stringify(spec, null, 2); const diffOps = diffLines(props.baseline.split('\n'), currentText.split('\n')); @@ -646,6 +699,31 @@ function McpEditorBody(props: { )} + {/* Unassigned Targets */} + {unassignedTargets.length > 0 && ( + + + + {unassignedTargets.map((target, idx) => { + const targetName = target.name ?? `Target ${idx + 1}`; + const targetType = target.targetType; + const endpoint = target.endpoint; + const displayInfo = endpoint ?? target.compute?.host ?? targetType; + return ( + + + {targetName} + + ({targetType} · {displayInfo}) + + + ); + })} + + + + )} + {dirty && ( ● Changes pending