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
41 changes: 39 additions & 2 deletions src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
}
Expand All @@ -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: {
Expand Down Expand Up @@ -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';

Expand All @@ -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;
}

/**
Expand All @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions src/assets/cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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');
}
Expand All @@ -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: {
Expand Down
27 changes: 25 additions & 2 deletions src/assets/cdk/lib/cdk-stack.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
}

/**
Expand All @@ -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',
Expand Down
12 changes: 6 additions & 6 deletions src/cli/cloudformation/__tests__/outputs-extended.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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({});
});
});
4 changes: 3 additions & 1 deletion src/cli/cloudformation/__tests__/outputs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('buildDeployedState', () => {
'default',
'TestStack',
agents,
{},
undefined,
'arn:aws:kms:us-east-1:123456789012:key/abc-123'
);
Expand All @@ -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();
});
Expand All @@ -52,6 +53,7 @@ describe('buildDeployedState', () => {
'dev',
'DevStack',
{},
{},
existingState,
'arn:aws:kms:us-east-1:123456789012:key/dev-key'
);
Expand Down
49 changes: 49 additions & 0 deletions src/cli/cloudformation/outputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
): Record<string, { gatewayId: string; gatewayArn: string }> {
const gateways: Record<string, { gatewayId: string; gatewayArn: string }> = {};

// 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.
*
Expand Down Expand Up @@ -132,6 +173,7 @@ export function buildDeployedState(
targetName: string,
stackName: string,
agents: Record<string, AgentCoreDeployedState>,
gateways: Record<string, { gatewayId: string; gatewayArn: string }>,
existingState?: DeployedState,
identityKmsKeyArn?: string
): DeployedState {
Expand All @@ -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,
Expand Down
35 changes: 20 additions & 15 deletions src/cli/commands/add/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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 });
});
});

Expand Down
Loading
Loading