Guide · AWS DevOps

MCP Server CDK — AWS CDK v2 infrastructure as code for ECS, IAM, VPC, and secrets

AWS CDK v2 lets MCP server teams define their entire infrastructure (ECS clusters, VPCs, IAM roles, SQS queues, ECR repositories, Secrets Manager entries) in TypeScript and synthesize it to CloudFormation. The four CDK concepts that catch first-time users: bootstrap is mandatory (CDK requires a bootstrap stack in every account+region combination before any CDK deployment — cdk bootstrap aws://ACCOUNT/REGION deploys an S3 bucket and IAM roles that CDK uses to store assets; without it, cdk deploy fails with "This stack uses assets, so the toolkit stack must be deployed"), construct levels (L1 = raw CloudFormation resources; L2 = higher-level with sane defaults and grant methods; L3/patterns = complete multi-resource architectures — use L2 constructs for individual services and L3 only when the full pattern fits your use case), RemovalPolicy defaults to RETAIN for stateful resources (DynamoDB tables, S3 buckets, RDS instances, ECR repositories, and Secrets Manager secrets all default to RemovalPolicy.RETAIN — tearing down a CDK stack does not delete these resources unless you explicitly set RemovalPolicy.DESTROY), and always run cdk diff before cdk deploy (diff shows you exactly what CloudFormation changes will be applied — skipping it on stateful stacks that have RDS or DynamoDB is how production databases get replaced).

TL;DR

Bootstrap every account+region before first deploy. Use L2 constructs (not L1 CfnResource). Set RemovalPolicy.RETAIN explicitly on production databases, ECR repos, and secrets. Always run cdk diff before cdk deploy in CI. Commit cdk.context.json — it caches VPC and AZ lookups that are required for reproducible synth.

Project setup and bootstrap

# Initialize a new CDK project
npx cdk init app --language typescript
cd my-mcp-infra

# Bootstrap account+region (one-time per account+region)
# Uses your current AWS credentials / profile
npx cdk bootstrap aws://123456789012/us-east-1

# For multi-account setups: bootstrap each target account
npx cdk bootstrap aws://PROD_ACCOUNT/us-east-1 --trust TOOLS_ACCOUNT --cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess

# Synthesize without deploying (use in CI to detect drift)
npx cdk synth

# Show what will change before deploying
npx cdk diff McpServerStack

# Deploy
npx cdk deploy McpServerStack

The bootstrap stack (CDKToolkit) creates an S3 bucket for CloudFormation templates and Lambda/Docker assets, an ECR repository for Docker assets, and several IAM roles. It must be in the same account and region as the stack you are deploying. Running cdk bootstrap again on an already-bootstrapped account is safe — it upgrades the bootstrap stack if a newer CDK version introduced changes.

Complete MCP server stack: ECS + ECR + SQS + Secrets Manager

import * as cdk from "aws-cdk-lib";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as ecs from "aws-cdk-lib/aws-ecs";
import * as ecr from "aws-cdk-lib/aws-ecr";
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import * as logs from "aws-cdk-lib/aws-logs";
import { Construct } from "constructs";

export class McpServerStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC: 2 AZs, public + private subnets, NAT gateway for outbound
    const vpc = new ec2.Vpc(this, "Vpc", {
      maxAzs: 2,
      natGateways: 1,   // 1 NAT = $32/mo; set 0 for dev, 2 for prod HA
    });

    // ECR repository for the MCP server image
    const repository = new ecr.Repository(this, "McpRepo", {
      repositoryName: "mcp-server",
      imageTagMutability: ecr.TagMutability.IMMUTABLE,
      imageScanOnPush: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN,    // don't delete on stack destroy
      lifecycleRules: [
        { tagStatus: ecr.TagStatus.UNTAGGED, maxImageAge: cdk.Duration.days(1) },
        { tagStatus: ecr.TagStatus.ANY, maxImageCount: 50 },
      ],
    });

    // SQS queue for async tool execution
    const dlq = new sqs.Queue(this, "Dlq", {
      queueName: "mcp-server-dlq",
      retentionPeriod: cdk.Duration.days(14),
    });
    const queue = new sqs.Queue(this, "Queue", {
      queueName: "mcp-server-jobs",
      visibilityTimeout: cdk.Duration.seconds(300),
      deadLetterQueue: { queue: dlq, maxReceiveCount: 3 },
    });

    // Database password in Secrets Manager (auto-rotated)
    const dbSecret = new secretsmanager.Secret(this, "DbSecret", {
      secretName: "/mcp-server/prod/db-password",
      generateSecretString: {
        secretStringTemplate: JSON.stringify({ username: "mcp" }),
        generateStringKey: "password",
        excludePunctuation: true,
        passwordLength: 32,
      },
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    // ECS cluster
    const cluster = new ecs.Cluster(this, "Cluster", { vpc });

    // Task definition with execution + task roles
    const taskDef = new ecs.FargateTaskDefinition(this, "TaskDef", {
      cpu: 512,
      memoryLimitMiB: 1024,
    });

    // Grant ECR pull to execution role (automatic via addContainer with ecr image)
    const container = taskDef.addContainer("app", {
      image: ecs.ContainerImage.fromEcrRepository(repository, "latest"),
      // ^ this automatically grants execution role ECR pull permissions
      portMappings: [{ containerPort: 3000 }],
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: "mcp-server",
        logRetention: logs.RetentionDays.ONE_MONTH,
      }),
      secrets: {
        // Inject from Secrets Manager — execution role gets read permission automatically
        DB_PASSWORD: ecs.Secret.fromSecretsManager(dbSecret, "password"),
      },
      environment: {
        SQS_QUEUE_URL: queue.queueUrl,
        NODE_ENV: "production",
      },
      healthCheck: {
        command: ["CMD-SHELL", "curl -f http://localhost:3000/health || exit 1"],
        interval: cdk.Duration.seconds(30),
        timeout: cdk.Duration.seconds(5),
        retries: 3,
        startPeriod: cdk.Duration.seconds(60),
      },
    });

    // Grant task role SQS permissions (application code)
    queue.grantSendMessages(taskDef.taskRole);
    queue.grantConsumeMessages(taskDef.taskRole);

    // ALB + Fargate service
    const alb = new elbv2.ApplicationLoadBalancer(this, "Alb", {
      vpc,
      internetFacing: true,
    });
    const service = new ecs.FargateService(this, "Service", {
      cluster,
      taskDefinition: taskDef,
      desiredCount: 2,
      circuitBreaker: { rollback: true },
      deploymentController: { type: ecs.DeploymentControllerType.ECS },
    });

    const listener = alb.addListener("Https", { port: 443, open: true });
    listener.addTargets("McpServer", {
      port: 3000,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targets: [service],
      healthCheck: {
        path: "/health",
        healthyHttpCodes: "200",
        interval: cdk.Duration.seconds(30),
        timeout: cdk.Duration.seconds(5),
      },
      deregistrationDelay: cdk.Duration.seconds(60),
    });

    new cdk.CfnOutput(this, "AlbDns", { value: alb.loadBalancerDnsName });
  }
}

Cross-stack references and context

When an MCP server project grows to multiple stacks (e.g., a NetworkStack for VPC and a ServiceStack for ECS), cross-stack references create implicit CloudFormation dependencies — changing the exported value forces both stacks to update together.

// Stack A: export VPC
export class NetworkStack extends cdk.Stack {
  public readonly vpc: ec2.Vpc;
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    this.vpc = new ec2.Vpc(this, "Vpc", { maxAzs: 2 });
    // CDK automatically creates a CloudFormation Export for this.vpc.vpcId
    // when another stack references this.vpc
  }
}

// Stack B: import VPC
const networkStack = new NetworkStack(app, "Network");
const serviceStack = new ServiceStack(app, "Service", {
  vpc: networkStack.vpc,   // creates cross-stack CloudFormation reference
});

// IMPORTANT: deploy NetworkStack before ServiceStack
// "cdk deploy --all" deploys in dependency order automatically

The cdk.context.json file caches AWS environment lookups (VPC IDs, AZ names, AMI IDs) that CDK fetches during cdk synth. Commit this file — without it, cdk synth in CI makes live AWS API calls that fail unless the CI role has broad read access. Delete entries from cdk.context.json only when you intentionally want CDK to re-query (e.g., after a VPC is replaced).

Common failure modes

SymptomCauseFix
"This stack uses assets, so the toolkit stack must be deployed"Bootstrap not run in this account+regionRun cdk bootstrap aws://ACCOUNT/REGION
cdk synth makes live AWS API calls in CI and failscdk.context.json not committed — CDK must look up VPC/AZ valuesCommit cdk.context.json to the repository
Stack teardown leaves dangling RDS / DynamoDB / S3 resourcesDefault RemovalPolicy.RETAIN on stateful resourcesExplicitly set removalPolicy: cdk.RemovalPolicy.RETAIN (production) or DESTROY (dev) — never rely on the default
ECS service won't update — deployment loop or rollbackNew task definition fails health checks; circuit breaker triggers rollbackCheck new task logs in CloudWatch; fix the health check or application error
"Resource handler returned message: Export X cannot be deleted while stack Y is using it"Cross-stack CloudFormation export is still referenced by another stackDeploy the dependent stack first to update the reference, then update the exporting stack
Stack name collision: two CDK apps deploy to the same stack nameDefault stack name = CDK class name; different apps with same class names collideSet explicit stackName property in every Stack constructor
cdk deploy shows "no changes" but ECS service uses old task definitionImage tag is :latest — CDK compares task definition JSON, not the actual image content; :latest doesn't change the JSONUse git SHA as the image tag: ecs.ContainerImage.fromEcrRepository(repo, gitSha) — each SHA produces a new task definition revision