AWS CDK Advanced · 2026-11-07 · CDK advanced arc
AWS CDK Advanced Patterns for MCP Servers: CDK Pipelines, Custom Resources, Testing, and Multi-Account Environments
AliveMCP monitors MCP endpoints every 60 seconds, and among the AWS-deployed servers we probe, CDK infrastructure failures cluster around four advanced patterns that catch nearly every team at scale: a server whose CDK Pipelines synth step fails in CI with "Need to perform AWS calls for CONTEXT" because the team updated a VPC lookup or added an Availability Zone query to a stack but ran cdk synth locally without committing the resulting cdk.context.json — the pipeline's CodeBuild project has no live AWS credentials to perform the lookup, the synth fails on every push, and the team can't deploy even non-infrastructure changes until they reproduce the lookup locally and commit the context file; a server whose CDK custom resource gets stuck in UPDATE_ROLLBACK_IN_PROGRESS because the PhysicalResourceId returned by the AwsCustomResource was set to cr.PhysicalResourceId.fromResponse('Version') for an SSM parameter, so when the SSM parameter updated and its version number incremented from 3 to 4, CloudFormation interpreted the new physical ID as a resource replacement — it called the delete handler on the old "resource" (version 3) and the create handler on the new "resource" (version 4), deleting the SSM parameter mid-deploy and leaving downstream stacks without their config value; a server whose CDK test suite shows green across all 47 unit tests but fails on deploy because every test uses template.toJSON() snapshot testing and the team blindly accepted a snapshot update after a CDK minor-version upgrade that silently changed the IAM policy statement format — the snapshots now encode the wrong policy, the tests continue to pass against the stale snapshots, and nobody catches that the Fargate task role lost the dynamodb:PutItem permission until production returns AccessDeniedException; and a server whose CDK environment management breaks when deploying to a staging account because the team uses Vpc.fromLookup(this, 'Vpc', { vpcId: vpc-0abc123 }) in an env-agnostic stack — omitting the explicit env: { account, region } props means CDK can't cache the lookup result in cdk.context.json, the synth always makes a live AWS API call, and in a cross-account CI environment where credentials resolve to the pipeline account (not staging), the lookup returns the wrong VPC or errors with "No VPCs matched". Four advanced CDK patterns form the complete infrastructure-as-code stack for production MCP server projects. CDK Pipelines (aws-cdk-lib/pipelines) creates a self-mutating CodePipeline that updates its own pipeline definition when you push — a ShellStep synth produces the Cloud Assembly, a Stage subclass wraps your stacks per environment, pipeline.addStage() gates promotion through dev → staging → prod with optional ManualApprovalStep pre-hooks and integration test post-hooks, and codeBuildDefaults handles VPC placement and IAM customization across all CodeBuild projects. CDK custom resources extend CloudFormation with arbitrary provisioning logic — AwsCustomResource (from aws-cdk-lib/custom-resources) covers single-API-call cases inline without writing a Lambda, and the cr.Provider framework covers complex multi-step or async provisioning where you supply onEventHandler and isCompleteHandler Lambdas. CDK testing (aws-cdk-lib/assertions) lets you synthesize a stack into a Template and assert on its CloudFormation JSON without deploying — fine-grained assertions with hasResourceProperties and Match matchers catch regressions, Capture extracts CDK-generated logical IDs for structural assertions, and snapshot testing with template.toJSON() provides full-template regression coverage. CDK environments and Stage manage multi-account deployments — explicit env: { account, region } enables VPC lookups and deterministic synthesis, the Stage construct bundles stacks per environment with typed props, context variables in cdk.json inject feature flags accessible from any construct, and cross-account SSM parameter sharing bridges the gap when CloudFormation exports can't reach across account boundaries. This guide synthesizes all four advanced CDK patterns into a complete infrastructure-as-code playbook for MCP server builders.
TL;DR
- CDK Pipelines — install
aws-cdk-lib; create aCodePipelineconstruct with aShellStepsynth (npm ci && npx cdk synth); wrap your MCP server stacks in acdk.Stagesubclass; callpipeline.addStage(new McpServerStage(...))per environment; addManualApprovalStepbefore prod; runcdk bootstrap aws://TARGET_ACCOUNT/REGION --trust PIPELINE_ACCOUNT --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccessin every target account; commitcdk.context.jsonso synth never makes live AWS calls in CI; useCodeBuildStepinstead ofShellStepwhen you needprivileged: truefor Docker builds; usepipeline.addWave('MultiRegion')for simultaneous multi-region deployment; callpipeline.buildPipeline()before accessingpipeline.pipelineto wire CodeStar Notifications;envFromCfnOutputsin post-stepShellSteppasses real deployed URLs into integration tests: The self-mutation mechanism is CDK Pipelines' defining feature — the pipeline runscdk deployon the pipeline stack itself as the first action before your app stages, so pushing a new stage or approval step takes effect on the next run without manual CloudFormation console work. The tradeoff: the pipeline account hosts the CodePipeline, CodeBuild projects, and S3 artifact bucket; target accounts hold the app stacks. Bootstrap must be run once per target account+region before the first deploy and re-run after major CDK version upgrades that add new IAM roles to the bootstrap stack. - CDK custom resources — use
cr.AwsCustomResourcefor single-SDK-call provisioning tasks (enabling account-level service features, fetching values from resources not in CloudFormation, registering endpoints); setserviceto the AWS SDK v3 client name without theClientsuffix ('SESv2'forSESv2Client) andactionto the command name without theCommandsuffix ('PutAccountSuppressionAttributes'forPutAccountSuppressionAttributesCommand); always set a stablePhysicalResourceId.of('fixed-string')when the resource has no meaningful server-assigned ID; usePhysicalResourceId.fromResponse('fieldName')only when you want CloudFormation replacement semantics on value changes; callresource.getResponseField('Field.NestedField')(dot notation) to pass response values to other constructs as CDK tokens; grant least-privilege IAM viacr.AwsCustomResourcePolicy.fromStatements([]); usecr.Providerframework when you need multi-step logic, error handling, or async polling — supplyonEventHandlerLambda (handles Create/Update/Delete, returns PhysicalResourceId) and optionallyisCompleteHandlerLambda (called everyqueryIntervaluntil it returns{ IsComplete: true }); prevent Lambda re-creation across stacks withstack.node.tryFindChild('provider-id')singleton pattern: The most common AwsCustomResource mistake is confusing the IAM grant: the custom resource's internal Lambda needs IAM permissions to call the SDK methods you specified —cr.AwsCustomResourcePolicy.fromStatements()attaches a policy to that Lambda's role. Your CDK deploy role does NOT need those permissions; the Lambda does. Missing this distinction produces deployment failures that look like permission errors but appear in CloudFormation events as "Custom Resource failed", not as IAM errors from your deploy credentials. - CDK testing — import
TemplateandMatchfromaws-cdk-lib/assertions; create a test CDK app, instantiate your stack, callTemplate.fromStack(stack)— no AWS connection needed; usetemplate.hasResourceProperties('AWS::ECS::TaskDefinition', { ... })for specific property assertions (partial match by default); usetemplate.resourceCountIs('AWS::ECS::Service', 1)for count assertions; usetemplate.allResourcesProperties('AWS::S3::Bucket', { AccessControl: Match.absent() })for security invariants across all matching resources; useMatch.objectLikefor partial matching,Match.arrayWithfor arrays that may have extra items,Match.stringLikeRegexpfor string patterns,Match.absent()to assert a key is missing,Match.not(pattern)to negate; useCaptureto extract CDK-generated logical IDs and token-resolved ARNs from matched resources and assert on them structurally; test conditional stack props by instantiating the stack twice with different values; usetemplate.toJSON()as a Jest snapshot for full-template regression coverage but always review snapshot diffs — never blindly accept them on CDK upgrades; usetemplate.findResources('AWS::ECS::TaskDefinition')to snapshot specific resource types rather than the entire template: CDK tests verify that your code generates the right CloudFormation template — they do not verify the template deploys correctly. A test that proves an IAM role has the right policy document does not prove CloudFormation will accept it, that the trust relationship is valid, or that the resulting role can actually assume the right service. For production MCP server infrastructure, supplement unit tests with integration tests usingenvFromCfnOutputsin a CDK Pipelines post-step that hits real deployed endpoints. - CDK environments — always set explicit
env: { account: '123456789012', region: 'us-east-1' }on stacks that doVpc.fromLookup, AZ queries (cdk.Stack.availabilityZones), orFn.importValuecross-stack references; env-agnostic stacks (noenv) can't do lookups and emit${AWS::AccountId}tokens in the CloudFormation rather than resolved IDs; usecdk.Stageto bundle network + database + server stacks per environment with typed props interface extendingcdk.StageProps— instantiate once per environment inbin/app.tswith differentdesiredCount,rdsInstanceClass,enableWaf; pass environment-specific configuration through typedStageProps, not env vars; store feature flags and runtime config incdk.jsonundercontext, read withthis.node.tryGetContext('key') ?? default— always provide defaults; override at deploy time withcdk deploy -c mcp:flag=true; for cross-account value sharing, write to SSM in source account (ssm.StringParameter+grantReadto target CDK deploy role), read in target account withcr.AwsCustomResource(SSM GetParameter call) — CloudFormation cross-stack exports (CfnOutput+Fn.importValue) only work within the same account+region; useRemovalPolicy.RETAINfor prod databases,RemovalPolicy.DESTROYfor dev; name resources withcdk.Stack.of(this).stackNameprefix to avoid cross-environment collisions: The env-agnostic vs explicit environment distinction is the most consequential CDK decision for multi-account MCP projects. Env-agnostic stacks are portable and re-deployable anywhere — useful for utility stacks and open-source modules. Explicit-env stacks are deterministic and can do lookups — required for anything that references VPCs, subnets, ACM certificates, or existing hosted zones by ID. When in doubt, use explicit environments and commitcdk.context.json; you can always make a stack more portable later, but adding lookup capability to an env-agnostic stack requires changing the deployment model.
Pattern 1: Self-mutating CI/CD pipeline with CDK Pipelines
When an MCP server on ECS Fargate needs automated deployment across dev, staging, and production accounts, CDK Pipelines is the infrastructure-as-code answer. The model: a single CodePipeline construct in a dedicated pipeline account drives deployments to all target accounts. The pipeline's first stage always updates the pipeline definition itself — push a new environment or approval step and the pipeline self-heals on the next run without console intervention.
Minimal pipeline with one application stage
A CDK Pipelines setup requires three components: the CodePipeline construct (the pipeline infrastructure), a synth step (CodeBuild job producing the Cloud Assembly), and a Stage subclass wrapping your stacks.
import { CodePipeline, CodePipelineSource, ShellStep } from 'aws-cdk-lib/pipelines';
import { McpServerStage } from './stages/mcp-server-stage';
export class McpPipelineStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const pipeline = new CodePipeline(this, 'Pipeline', {
pipelineName: 'McpServerPipeline',
synth: new ShellStep('Synth', {
input: CodePipelineSource.gitHub('your-org/mcp-server', 'main', {
authentication: cdk.SecretValue.secretsManager('github-token'),
}),
commands: ['npm ci', 'npx cdk synth'],
}),
});
// Dev stage — auto-deploys on every push
pipeline.addStage(new McpServerStage(this, 'Dev', {
env: { account: '111111111111', region: 'us-east-1' },
}));
// Prod stage — requires manual approval
pipeline.addStage(new McpServerStage(this, 'Prod', {
env: { account: '222222222222', region: 'us-east-1' },
}), {
pre: [new cdk.pipelines.ManualApprovalStep('PromoteToProd')],
});
}
}
The McpServerStage class extends cdk.Stage and instantiates your stacks. All stacks inside a Stage deploy as a unit — if any stack fails, the entire stage fails and the pipeline stops without touching the next stage.
export class McpServerStage extends cdk.Stage {
public readonly serviceUrl: cdk.CfnOutput;
constructor(scope: Construct, id: string, props: McpServerStageProps) {
super(scope, id, props);
const dbStack = new McpDatabaseStack(this, 'Database', { env: props.env });
const serverStack = new McpServerStack(this, 'Server', {
env: props.env,
table: dbStack.table,
desiredCount: props.desiredCount ?? 2,
});
this.serviceUrl = serverStack.serviceUrl; // exposed for integration tests
}
}
Bootstrap requirements and integration tests
Every target account must be bootstrapped with --trust before the pipeline can deploy to it. The CDK Pipelines deploy mechanism creates IAM roles in target accounts that the pipeline account assumes — without the trust relationship, all cross-account deploys fail with "is not authorized to assume role".
# Pipeline account: no --trust needed for self
npx cdk bootstrap aws://PIPELINE_ACCOUNT/us-east-1
# Every target account: trust the pipeline account
npx cdk bootstrap aws://DEV_ACCOUNT/us-east-1 \
--trust PIPELINE_ACCOUNT \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
Post-deploy integration tests receive real deployed URLs via envFromCfnOutputs:
const devStage = pipeline.addStage(new McpServerStage(this, 'Dev', {
env: { account: '111111111111', region: 'us-east-1' },
}), {
post: [
new ShellStep('IntegTest', {
commands: [
'npm ci',
'curl -f $MCP_URL/health', // hits the real deployed endpoint
'npm run test:integ',
],
envFromCfnOutputs: {
MCP_URL: devStage.stage.serviceUrl, // resolved CfnOutput value
},
}),
],
});
Docker builds and VPC placement
Use CodeBuildStep instead of ShellStep when the synth step builds Docker images (needs privileged: true). Use codeBuildDefaults on the pipeline construct to place all CodeBuild projects inside a VPC or add IAM policies to all projects at once.
const pipeline = new CodePipeline(this, 'Pipeline', {
dockerEnabledForSynth: true,
codeBuildDefaults: {
vpc,
rolePolicy: [
new iam.PolicyStatement({
actions: ['ssm:GetParameter'],
resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter/mcp/*`],
}),
],
},
synth: new CodeBuildStep('Synth', {
input: CodePipelineSource.gitHub('your-org/mcp-server', 'main', {
authentication: cdk.SecretValue.secretsManager('github-token'),
}),
buildEnvironment: { privileged: true },
commands: ['npm ci', 'npm run test', 'npx cdk synth'],
}),
});
For simultaneous multi-region deployment, add stages to a wave — all stages in a wave deploy in parallel. A single ManualApprovalStep in the wave's pre array gates all regions with one approval:
const globalWave = pipeline.addWave('GlobalProd');
globalWave.addStage(new McpServerStage(this, 'ProdUsEast1', {
env: { account: '222222222222', region: 'us-east-1' },
}));
globalWave.addStage(new McpServerStage(this, 'ProdEuWest1', {
env: { account: '222222222222', region: 'eu-west-1' },
}));
See the CDK Pipelines reference guide for the full failures table covering synth context errors, cross-account bootstrap failures, pipeline self-mutation loops, and integration test envFromCfnOutputs mismatches. For the base CDK constructs that your Stage stacks build on, see the CDK basics guide.
Pattern 2: Extending CloudFormation with CDK custom resources
CloudFormation's built-in resource types cover most AWS services but leave gaps: enabling account-level service features, fetching values from resources created outside your stack, registering endpoints in third-party services, or performing async provisioning that requires polling. CDK custom resources fill those gaps with two tools that cover the full complexity spectrum.
AwsCustomResource — inline SDK calls without writing a Lambda
AwsCustomResource takes three optional AwsSdkCall descriptors — onCreate, onUpdate, onDelete — and creates a single managed Lambda that dispatches to the right SDK call per CloudFormation event. No Lambda function to write or maintain.
import * as cr from 'aws-cdk-lib/custom-resources';
// Enable SES account-level suppression on stack creation
new cr.AwsCustomResource(this, 'EnableSuppression', {
onCreate: {
service: 'SESv2', // SDK v3 client name without 'Client'
action: 'PutAccountSuppressionAttributes', // command name without 'Command'
parameters: {
SuppressedReasons: ['BOUNCE', 'COMPLAINT'],
},
physicalResourceId: cr.PhysicalResourceId.of('ses-suppression-enabled'),
},
onDelete: {
service: 'SESv2',
action: 'PutAccountSuppressionAttributes',
parameters: { SuppressedReasons: [] },
physicalResourceId: cr.PhysicalResourceId.of('ses-suppression-enabled'),
},
policy: cr.AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
actions: ['ses:PutAccountSuppressionAttributes'],
resources: ['*'],
}),
]),
});
The physicalResourceId rule is critical: use PhysicalResourceId.of('fixed-string') when the resource has no server-assigned ID or when you do not want CloudFormation replacement semantics on updates. Use PhysicalResourceId.fromResponse('Field') only when you deliberately want CloudFormation to treat a changed response value as a new resource (triggering delete of the old one and create of the new one).
Reading output values and cross-stack lookups
getResponseField('Field.NestedField') returns a CDK token that resolves to a field from the last SDK response — usable anywhere a string token is accepted (ECS environment variable, Lambda env var, another custom resource parameter).
// Look up an SSM parameter written by another team's stack (cross-stack read)
const registryUrl = new cr.AwsCustomResource(this, 'FetchRegistryUrl', {
onCreate: {
service: 'SSM',
action: 'GetParameter',
parameters: { Name: '/shared/mcp/registry-url', WithDecryption: true },
physicalResourceId: cr.PhysicalResourceId.of('registry-url-lookup'),
},
onUpdate: {
service: 'SSM',
action: 'GetParameter',
parameters: { Name: '/shared/mcp/registry-url', WithDecryption: true },
physicalResourceId: cr.PhysicalResourceId.of('registry-url-lookup'),
},
policy: cr.AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
actions: ['ssm:GetParameter'],
resources: [`arn:aws:ssm:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:parameter/shared/mcp/*`],
}),
]),
});
// Use the resolved value as an ECS environment variable
const mcpRegistryUrl = registryUrl.getResponseField('Parameter.Value');
Provider framework — complex async provisioning
Use cr.Provider when AwsCustomResource falls short: multiple SDK calls, conditional logic, or async completion that requires polling. Supply an onEventHandler Lambda that handles Create/Update/Delete events and optionally an isCompleteHandler Lambda polled at queryInterval until it returns { IsComplete: true }.
const onEventHandler = new lambda.Function(this, 'RegistryOnEvent', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.onEvent',
code: lambda.Code.fromAsset('lambda/mcp-registry-custom-resource'),
timeout: cdk.Duration.minutes(5),
});
registryTable.grantReadWriteData(onEventHandler);
const provider = new cr.Provider(this, 'RegistryProvider', {
onEventHandler,
queryInterval: cdk.Duration.seconds(10),
totalTimeout: cdk.Duration.minutes(30),
});
const registryEntry = new cdk.CustomResource(this, 'McpRegistryEntry', {
serviceToken: provider.serviceToken,
resourceType: 'Custom::McpRegistryEntry',
properties: {
ServerName: 'my-mcp-server',
EndpointUrl: mcpService.loadBalancerDnsName,
},
});
The onEventHandler Lambda contract: receive a CloudFormation event with RequestType ('Create', 'Update', 'Delete'), return a PhysicalResourceId (and optionally a Data map for Fn::GetAtt outputs). Never change the physical ID on Update unless you intend CloudFormation to delete-then-recreate the resource.
The full provider framework patterns, singleton provider pattern for sharing a Provider across multiple stacks, and the AwsCustomResource lookup approach for cross-account SSM parameters are covered in the CDK custom resources reference guide. The cross-account SSM pattern specifically connects to the CDK environments guide since the lookup uses the target account's CDK deploy role.
Pattern 3: Unit testing CDK stacks with Template assertions
CDK's assertions library (aws-cdk-lib/assertions) lets you synthesize a stack into a Template object and assert against the generated CloudFormation JSON — without deploying to AWS. For MCP server CDK stacks that provision ECS Fargate tasks, DynamoDB tables, IAM roles, and CloudWatch log groups, unit tests catch configuration regressions before they reach a real environment.
Basic setup and fine-grained assertions
Create a test CDK app, instantiate your stack, call Template.fromStack(stack). No AWS credentials needed — synthesis is purely local.
import * as cdk from 'aws-cdk-lib';
import { Template, Match } from 'aws-cdk-lib/assertions';
import { McpServerStack } from '../lib/mcp-server-stack';
describe('McpServerStack', () => {
let template: Template;
beforeEach(() => {
const app = new cdk.App();
const stack = new McpServerStack(app, 'TestStack', {
env: { account: '123456789012', region: 'us-east-1' },
});
template = Template.fromStack(stack);
});
test('creates Fargate task definition with correct CPU and memory', () => {
template.hasResourceProperties('AWS::ECS::TaskDefinition', {
Cpu: '512',
Memory: '1024',
RequiresCompatibilities: ['FARGATE'],
});
});
test('creates exactly one ECS service', () => {
template.resourceCountIs('AWS::ECS::Service', 1);
});
// Security invariant: no S3 bucket should have a public ACL
test('no bucket has public access', () => {
template.allResourcesProperties('AWS::S3::Bucket', {
AccessControl: Match.absent(),
});
});
});
hasResourceProperties performs a partial match by default — the resource must have all specified properties but may have additional ones. This is intentional: CDK adds many derived properties that you rarely want to assert on. Use Match.exact(value) to enforce complete equality when it matters.
Match matchers for precise structural assertions
The Match class provides matchers that nest anywhere in an assertion's property object. The most important ones for CDK test suites:
| Matcher | Use case |
|---|---|
Match.objectLike(subset) | Object must contain all keys in subset (default for hasResourceProperties) |
Match.arrayWith(items) | Array must contain all items (order-independent, may have extras) |
Match.stringLikeRegexp(pattern) | String must match the regex |
Match.absent() | Key must not be present — essential for security assertions |
Match.not(pattern) | Negates any matcher |
test('task definition has MCP_PORT env var and port mapping', () => {
template.hasResourceProperties('AWS::ECS::TaskDefinition', {
ContainerDefinitions: Match.arrayWith([
Match.objectLike({
Name: 'mcp-server',
Environment: Match.arrayWith([
{ Name: 'MCP_PORT', Value: '3000' },
]),
PortMappings: Match.arrayWith([
Match.objectLike({ ContainerPort: 3000, Protocol: 'tcp' }),
]),
}),
]),
});
});
test('task role has DynamoDB permissions', () => {
template.hasResourceProperties('AWS::IAM::Policy', {
PolicyDocument: {
Statement: Match.arrayWith([
Match.objectLike({
Action: Match.arrayWith(['dynamodb:GetItem', 'dynamodb:PutItem']),
Effect: 'Allow',
}),
]),
},
});
});
Capture — asserting on CDK-generated values
Capture acts as a capture group inside a matcher: it matches any value and stores what it matched so you can assert on it after the fact. This is essential for asserting on CDK-generated logical IDs, token-resolved ARNs, or any value CDK generates dynamically at synthesis time.
import { Capture } from 'aws-cdk-lib/assertions';
test('ECS service references our task definition', () => {
const taskDefCapture = new Capture();
template.hasResourceProperties('AWS::ECS::Service', {
TaskDefinition: taskDefCapture,
});
// taskDefCapture.asString() is the CloudFormation ref CDK generated
expect(taskDefCapture.asString()).toMatch(/McpServerTask/);
});
test('log group retention is at least 30 days', () => {
const retentionCapture = new Capture();
template.hasResourceProperties('AWS::Logs::LogGroup', {
RetentionInDays: retentionCapture,
});
expect(retentionCapture.asNumber()).toBeGreaterThanOrEqual(30);
});
Snapshot testing — full template regression coverage
Snapshot testing with template.toJSON() captures the entire synthesized CloudFormation template. Any change — new resource, changed property, different logical ID — fails the snapshot test, forcing a deliberate update. This is cheap to add and catches a broad class of accidental regressions.
test('stack matches snapshot', () => {
expect(template.toJSON()).toMatchSnapshot();
});
// For large stacks: snapshot only specific resource types
test('ECS resources match snapshot', () => {
const taskDefs = template.findResources('AWS::ECS::TaskDefinition');
expect(taskDefs).toMatchSnapshot();
});
Always review snapshot diffs before accepting — never blindly run npx jest --updateSnapshot. CDK minor version upgrades frequently change IAM policy statement formatting or logical ID generation, producing large diffs that contain one meaningful change buried in noise. The discipline of reviewing diffs is what makes snapshot testing valuable; without it, it only confirms that the template is consistent with itself.
For testing conditional stack logic, instantiate the stack twice with different props and run separate describe blocks against each template variant. The CDK testing reference guide covers template.hasOutput for asserting on CfnOutput values, findResources for iterating all matching resources, and the full Match matchers table with nested usage examples.
Pattern 4: Multi-account environment management with Stage and context
MCP server projects that span dev, staging, and prod accounts need a consistent way to manage environment-specific configuration without duplicating stack code. CDK environments and Stage provide the right primitives: explicit env: { account, region } for deterministic synthesis, the Stage construct for per-environment stack bundling, and cdk.json context for feature flags and runtime config.
Explicit environments vs env-agnostic stacks
The choice between explicit and env-agnostic stacks determines what CDK can do at synthesis time and how the stack behaves in CI:
| Explicit environment | Env-agnostic |
|---|---|
env: { account: '...', region: '...' } | env omitted or uses CDK_DEFAULT_* |
| VPC lookups, AZ queries work in synth | Lookups fail — CloudFormation pseudo-parameters used |
| Deterministic synth — no live AWS calls | Synth requires credentials or env vars |
| Stack pinned to one account+region | Stack deploys wherever the CLI has credentials |
| Required for cross-stack CloudFormation exports | Can't be a target of cross-stack references from explicit stacks |
For MCP server stacks that look up a VPC by ID, use an explicit environment and commit cdk.context.json so synth never needs live AWS access in CI:
// Works only with explicit env — Vpc.fromLookup makes an AWS API call during
// first synth and caches the result in cdk.context.json
const vpc = ec2.Vpc.fromLookup(this, 'Vpc', { vpcId: 'vpc-0abc123' });
Stage construct — typed multi-environment bundling
The Stage construct groups related stacks and carries an env. Instantiate the same Stage class with different props for each environment:
interface McpEnvironmentStageProps extends cdk.StageProps {
domainName: string;
desiredCount: number;
rdsInstanceClass: string;
enableWaf: boolean;
alertEmail?: string;
}
export class McpEnvironmentStage extends cdk.Stage {
public readonly serviceUrl: cdk.CfnOutput;
constructor(scope: Construct, id: string, props: McpEnvironmentStageProps) {
super(scope, id, props);
const networkStack = new McpNetworkStack(this, 'Network', { env: props.env });
const dbStack = new McpDatabaseStack(this, 'Database', {
env: props.env,
vpc: networkStack.vpc,
instanceClass: props.rdsInstanceClass,
});
const serverStack = new McpServerStack(this, 'Server', {
env: props.env,
vpc: networkStack.vpc,
database: dbStack.database,
domainName: props.domainName,
desiredCount: props.desiredCount,
enableWaf: props.enableWaf,
});
this.serviceUrl = serverStack.serviceUrl;
}
}
// bin/app.ts — one instantiation per environment
const app = new cdk.App();
new McpEnvironmentStage(app, 'Dev', {
env: { account: '111111111111', region: 'us-east-1' },
domainName: 'dev.alivemcp.com',
desiredCount: 1,
rdsInstanceClass: 'db.t4g.micro',
enableWaf: false,
});
new McpEnvironmentStage(app, 'Prod', {
env: { account: '222222222222', region: 'us-east-1' },
domainName: 'alivemcp.com',
desiredCount: 3,
rdsInstanceClass: 'db.r7g.large',
enableWaf: true,
alertEmail: 'oncall@alivemcp.com',
});
Context variables and feature flags
CDK context (cdk.json under the context key) is the right place for feature flags and environment-specific runtime config that don't belong in StackProps. Context is accessible anywhere in the CDK construct tree via node.tryGetContext(key) — always provide defaults so constructs work without context being set.
// cdk.json
{
"context": {
"mcp:featureFlag:enableStreamingTools": true,
"mcp:featureFlag:enableRateLimit": false,
"mcp:monitoring:retentionDays": 90
}
}
// In a construct — read with defaults
const enableStreaming = this.node.tryGetContext('mcp:featureFlag:enableStreamingTools') ?? false;
const retentionDays = this.node.tryGetContext('mcp:monitoring:retentionDays') ?? 30;
Override context at deploy time without changing code: cdk deploy -c mcp:featureFlag:enableRateLimit=true. CLI context overrides cdk.json context, making this the CDK-idiomatic way to toggle behavior in CI pipelines.
Cross-account SSM parameter sharing
CloudFormation cross-stack exports (CfnOutput + Fn.importValue) only work within the same account+region. For cross-account value sharing — common when a shared-services account owns the VPC and app accounts own the servers — write to SSM in the source account and read it using AwsCustomResource in the target account.
// Shared-services account: publish VPC ID to SSM, grant target CDK deploy role read
new ssm.StringParameter(this, 'SharedVpcId', {
parameterName: '/shared/network/vpc-id',
stringValue: vpc.vpcId,
});
const appAccountDeployRole = iam.Role.fromRoleArn(
this, 'AppDeployRole',
`arn:aws:iam::${APP_ACCOUNT}:role/cdk-hnb659fds-cfn-exec-role-${APP_ACCOUNT}-${this.region}`
);
ssmParam.grantRead(appAccountDeployRole);
// App account: fetch VPC ID with AwsCustomResource (Vpc.fromLookup can't cross accounts)
const sharedVpcId = new cr.AwsCustomResource(this, 'SharedVpcId', {
onCreate: {
service: 'SSM',
action: 'GetParameter',
parameters: { Name: '/shared/network/vpc-id' },
physicalResourceId: cr.PhysicalResourceId.of('shared-vpc-id'),
},
onUpdate: {
service: 'SSM',
action: 'GetParameter',
parameters: { Name: '/shared/network/vpc-id' },
physicalResourceId: cr.PhysicalResourceId.of('shared-vpc-id'),
},
policy: cr.AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
actions: ['ssm:GetParameter'],
resources: [`arn:aws:ssm:${cdk.Aws.REGION}:${SHARED_ACCOUNT}:parameter/shared/network/*`],
}),
]),
});
// Hard-coded AZs required when VPC ID comes from a cross-account custom resource
const vpc = ec2.Vpc.fromVpcAttributes(this, 'Vpc', {
vpcId: sharedVpcId.getResponseField('Parameter.Value'),
availabilityZones: ['us-east-1a', 'us-east-1b'],
});
The Vpc.fromVpcAttributes call requires hard-coded AZs because CDK can't do an AZ lookup for a VPC it fetched via a custom resource — the lookup would happen at synth time but the VPC ID token isn't resolved until deploy time. The CDK environments reference guide covers the RemovalPolicy prod/dev split, stack naming with cdk.Stack.of(this).stackName prefix to avoid cross-environment collisions, and the full context variable patterns. For connecting environment management with CDK Pipelines, see how CDK Pipelines Stage reuses this same Stage class as the deployment unit.
Putting the four patterns together
A production MCP server deployment combines all four advanced CDK patterns. The Stage construct defines the per-environment stack bundle with typed props. Custom resources handle provisioning tasks CloudFormation doesn't natively support — SES suppression configuration on stack create, cross-account SSM lookups for shared VPC IDs, and async registration in a third-party registry. Unit tests verify that each stack synthesizes the expected resources and security invariants before any deploy. CDK Pipelines automates the full dev → staging → prod promotion sequence with integration tests that hit real deployed endpoints.
The common failure modes that bring down MCP servers monitored by AliveMCP cluster around three integration points: forgetting to commit cdk.context.json breaks CDK Pipelines synth in CI; changing a custom resource PhysicalResourceId unintentionally triggers CloudFormation delete-then-recreate of live resources; and treating snapshot test acceptance as a rubber stamp passes infrastructure with wrong IAM policies into production. All three are process failures as much as technical ones — but the CDK patterns above give you the guardrails to catch them before they reach the endpoint that AliveMCP is watching.
For the base CDK L1/L2/L3 construct model, bootstrap process, and RemovalPolicy defaults, see the CDK basics guide. For CDK Aspects — a cross-cutting construct visitor pattern for applying compliance rules to all stacks in a Stage — see the CDK Aspects guide. For higher-level CodePipeline constructs outside the CDK Pipelines abstraction, see the CodePipeline CDK guide. For Step Functions CDK patterns that orchestrate MCP server workflows alongside these deployment pipelines, that guide covers the CDK L2 constructs for state machines, tasks, and error handling.