Guide · AWS CDK · Environments & Stages
MCP Server CDK Environments — Stage, Cross-Account Stacks, Env-Agnostic Synthesis
CDK environments determine which AWS account and region a stack deploys to. Getting environment handling right is critical for MCP server projects that span multiple environments (dev, staging, prod) or multiple accounts. Four patterns cover everything: explicit environments (env: { account: '123456789012', region: 'us-east-1' } in StackProps) are required when your stack does VPC lookups, AZ queries, or any Fn.importValue that needs to be region-specific — explicit stacks synthesize deterministically without live AWS calls; env-agnostic stacks (omit env or use process.env.CDK_DEFAULT_ACCOUNT) work in any account/region but can't do lookups, can't create cross-stack references to explicit stacks, and produce ${AWS::AccountId} tokens in the CloudFormation instead of real account IDs; the Stage construct groups related stacks (network, database, application) into a deployable unit that can be promoted through environments with consistent props; and context variables in cdk.json let you inject feature flags and environment-specific configuration into any construct through the CDK context API.
TL;DR
Use explicit env: { account, region } for all stacks that do AWS lookups or cross-stack references. Use Stage to bundle stacks per environment. Pass environment-specific config (domain name, instance size, alert email) through typed StageProps rather than environment variables. Store feature flags in cdk.json under context and read them with this.node.tryGetContext('key').
Explicit environment vs env-agnostic stacks
The choice between explicit and env-agnostic stacks affects 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_* env vars |
| VPC lookups, AZ queries work in synth | Lookups fail — CloudFormation pseudo-parameters used instead |
| Deterministic synth — no live AWS calls needed | Synth requires AWS credentials or env vars set |
| Stack is pinned to one account+region | Stack deploys to whatever account/region the CLI has credentials for |
| Required for cross-stack CloudFormation exports | Can't be a target of cross-stack references from explicit stacks |
import * as cdk from 'aws-cdk-lib';
import { McpServerStack } from './mcp-server-stack';
const app = new cdk.App();
// Explicit environment — stack is pinned to this account+region
// VPC lookups and AZ queries work; synth is deterministic
const devStack = new McpServerStack(app, 'McpServerDev', {
env: { account: '111111111111', region: 'us-east-1' },
domainName: 'dev.alivemcp.com',
desiredCount: 1,
});
// Env-agnostic — deploys to whatever account the CLI has credentials for
// Use only for simple stacks with no lookups or cross-stack references
const portableStack = new McpServerStack(app, 'McpServerPortable', {
domainName: 'mcp.internal',
desiredCount: 2,
});
For MCP server stacks that reference 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 synth
// and caches the result in cdk.context.json
const vpc = ec2.Vpc.fromLookup(this, 'Vpc', {
vpcId: 'vpc-0abc123',
// alternately: vpcName: 'my-vpc'
});
Stage construct — bundling stacks per environment
cdk.Stage is a scope that groups related stacks and carries an environment. Wrapping stacks in a Stage means you can instantiate the same group of stacks for dev, staging, and prod by passing different props — the Stage handles the environment binding.
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { McpNetworkStack } from './mcp-network-stack';
import { McpDatabaseStack } from './mcp-database-stack';
import { McpServerStack } from './mcp-server-stack';
interface McpEnvironmentStageProps extends cdk.StageProps {
domainName: string;
desiredCount: number;
rdsInstanceClass: string;
enableWaf: boolean;
alertEmail?: string;
}
export class McpEnvironmentStage extends cdk.Stage {
// Expose outputs for CDK Pipelines integration tests
public readonly mcpServiceUrl: 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,
alertEmail: props.alertEmail,
});
this.mcpServiceUrl = serverStack.serviceUrl;
}
}
// In bin/app.ts — instantiate once 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',
});
Stacks inside a Stage inherit the Stage's env unless they specify their own. cdk deploy Dev/\* deploys all stacks in the Dev stage; cdk diff Prod/Network shows what would change in the Prod network stack.
Context variables and feature flags
CDK context (cdk.json under the context key) is the right way to inject environment-specific values and feature flags that don't belong in StackProps. Context is accessible anywhere in the CDK construct tree via node.tryGetContext(key).
// cdk.json
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"context": {
"mcp:featureFlag:enableStreamingTools": true,
"mcp:featureFlag:enableRateLimit": false,
"mcp:monitoring:retentionDays": 90,
// CDK built-in feature flags (set by cdk init; keep all true for new projects)
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true
}
}
// Reading context in a construct
export class McpServerConstruct extends Construct {
constructor(scope: Construct, id: string) {
super(scope, id);
const enableStreaming = this.node.tryGetContext('mcp:featureFlag:enableStreamingTools') ?? false;
const retentionDays = this.node.tryGetContext('mcp:monitoring:retentionDays') ?? 30;
if (enableStreaming) {
// Add streaming-specific resources
new McpStreamingSupport(this, 'Streaming');
}
new logs.LogGroup(this, 'Logs', {
retention: retentionDays,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
}
}
Context can also be set at deploy time with -c key=value on the CLI: cdk deploy -c mcp:featureFlag:enableRateLimit=true. CLI context overrides cdk.json context. This is the CDK-idiomatic way to toggle behavior in CI without changing code.
Cross-account SSM parameter sharing
When one stack needs a value from another stack in a different account, CloudFormation cross-stack exports (CfnOutput + Fn.importValue) only work within the same account+region. For cross-account sharing, write the value to SSM Parameter Store in the source account and read it from the target account using an AwsCustomResource lookup or a stack parameter.
// In the shared-services account: export the VPC ID to SSM
new ssm.StringParameter(this, 'SharedVpcId', {
parameterName: '/shared/network/vpc-id',
stringValue: vpc.vpcId,
description: 'Shared VPC ID for cross-account reference',
});
// Grant read to the application account's CDK deploy role
const appAccountDeployRole = iam.Role.fromRoleArn(
this, 'AppAccountDeployRole',
`arn:aws:iam::${APP_ACCOUNT}:role/cdk-hnb659fds-cfn-exec-role-${APP_ACCOUNT}-${this.region}`
);
ssmParam.grantRead(appAccountDeployRole);
// In the application account: read the SSM parameter using AwsCustomResource
// (because Vpc.fromLookup can't cross accounts)
import * as cr from 'aws-cdk-lib/custom-resources';
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-lookup'),
},
onUpdate: {
service: 'SSM',
action: 'GetParameter',
parameters: { Name: '/shared/network/vpc-id' },
physicalResourceId: cr.PhysicalResourceId.of('shared-vpc-id-lookup'),
},
policy: cr.AwsCustomResourcePolicy.fromStatements([
new iam.PolicyStatement({
actions: ['ssm:GetParameter'],
resources: [
// Cross-account SSM read — requires resource policy or VPC endpoint
`arn:aws:ssm:us-east-1:${SHARED_SERVICES_ACCOUNT}:parameter/shared/network/vpc-id`,
],
}),
]),
});
const vpcId = sharedVpcId.getResponseField('Parameter.Value');
const vpc = ec2.Vpc.fromVpcAttributes(this, 'SharedVpc', {
vpcId,
availabilityZones: ['us-east-1a', 'us-east-1b'], // hard-code from cdk.context.json
});
Environment-specific naming patterns
Avoid hardcoding environment names in resource names inside stacks — it makes stacks non-reusable across environments. Instead, use the Stage node path (cdk.Stage.of(this)?.stageName) or pass a suffix through props.
export class McpServerStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: McpServerStackProps) {
super(scope, id, props);
// Use Stack.of(this).stackName for globally unique names
// stackName includes the Stage prefix when deployed via a Stage
const tableNamePrefix = cdk.Stack.of(this).stackName;
new dynamodb.Table(this, 'McpRegistry', {
// e.g., 'Dev-Server-McpRegistry' or 'Prod-Server-McpRegistry'
tableName: `${tableNamePrefix}-registry`,
partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
removalPolicy: props.isProd
? cdk.RemovalPolicy.RETAIN
: cdk.RemovalPolicy.DESTROY,
});
// S3 bucket names must be globally unique — combine stack name + account
new s3.Bucket(this, 'McpAssets', {
bucketName: `mcp-assets-${tableNamePrefix.toLowerCase()}-${cdk.Aws.ACCOUNT_ID}`,
removalPolicy: props.isProd
? cdk.RemovalPolicy.RETAIN
: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: !props.isProd,
});
}
}
Common failures
| Symptom | Cause | Fix |
|---|---|---|
| "Lookup values cannot be used in env-agnostic stacks" | Vpc.fromLookup or similar called in a stack without explicit env | Add env: { account: '...', region: '...' } to the stack's StackProps |
| Dev stack change deploys to Prod accidentally | Both environments instantiated in the same CDK app; cdk deploy without a stack filter deploys all | Always specify the stack or stage: cdk deploy Dev/\* or cdk deploy McpServerDev |
node.tryGetContext returns undefined | Key not in cdk.json or key typo | Always provide a default: const val = this.node.tryGetContext('key') ?? defaultValue |
| Cross-account CloudFormation export fails | CloudFormation exports only work within the same account+region pair | Use SSM Parameter Store + AwsCustomResource or pass values as explicit stack parameters for cross-account sharing |
| Stage stacks deploy in wrong order | Stack dependencies within a Stage must be established via property passing — CDK doesn't auto-detect order | Pass outputs as properties: new ServerStack(this, 'Server', { vpc: networkStack.vpc }) — CDK creates a CloudFormation dependency automatically |