Guide · AWS CDK · CI/CD Pipelines
MCP Server CDK Pipelines — Self-Mutating CI/CD Pipeline with CodePipeline
CDK Pipelines (aws-cdk-lib/pipelines) creates a CodePipeline-backed deployment pipeline that can update itself when your pipeline definition changes — no manual CloudFormation console intervention required. The three concepts that define CDK Pipelines: self-mutation (the pipeline includes a "SelfMutate" stage that runs cdk deploy on the pipeline stack itself before proceeding to your app stages — push a new stage definition and the pipeline picks it up on the next run), the synth step (a ShellStep or CodeBuildStep that produces a Cloud Assembly directory via npx cdk synth — this is the blueprint CDK Pipelines reads to know which stacks to deploy and how), and Stage-based promotion (you wrap your CDK stacks in a Stage subclass, then call pipeline.addStage(new MyStage(...)) to gate deployment through environments like dev → staging → prod with optional pre/post steps, manual approvals, and integration test hooks).
TL;DR
Create a CodePipeline with a ShellStep synth that runs npx cdk synth. Wrap your MCP server stacks in a Stage subclass. Call pipeline.addStage(new McpServerStage(...)) for each environment. Add ManualApprovalStep before prod. Bootstrap every target account with --trust pointing to the pipeline account. Commit cdk.context.json so the synth step never makes live AWS lookups.
Package installation
CDK Pipelines is part of the main CDK v2 library — no separate package needed. The pipeline action itself provisions CodePipeline, CodeBuild projects, and an S3 artifact bucket in your pipeline account.
npm install aws-cdk-lib constructs
# CDK Pipelines is in aws-cdk-lib/pipelines — no separate install needed
# DevDependencies for the pipeline project itself:
npm install --save-dev aws-cdk typescript ts-node @types/node
Your pipeline project (bin/pipeline.ts) creates the CodePipeline construct. Your application stacks live in a separate Stage subclass. The two can share the same CDK app or live in different files — keeping them in the same repo means one push updates both the pipeline definition and your app stacks.
Minimal pipeline with one stage
A CDK Pipelines setup requires three components in your CDK app: the CodePipeline construct (the pipeline infrastructure), a ShellStep synth step (the CodeBuild job that produces the Cloud Assembly), and a Stage subclass (wraps one or more stacks that get deployed together).
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
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',
'npm run build',
'npx cdk synth',
],
}),
});
// Add dev stage — deployed automatically on every push
pipeline.addStage(new McpServerStage(this, 'Dev', {
env: { account: '111111111111', region: 'us-east-1' },
}));
// Add prod stage — requires manual approval before deploy
pipeline.addStage(new McpServerStage(this, 'Prod', {
env: { account: '222222222222', region: 'us-east-1' },
}), {
pre: [new cdk.pipelines.ManualApprovalStep('PromoteToProd')],
});
}
}
The McpServerStage class wraps your actual stacks. All stacks inside a Stage are deployed as a unit — if any stack fails, the entire stage fails and the pipeline stops.
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { McpServerStack } from '../lib/mcp-server-stack';
import { McpDatabaseStack } from '../lib/mcp-database-stack';
export class McpServerStage extends cdk.Stage {
constructor(scope: Construct, id: string, props?: cdk.StageProps) {
super(scope, id, props);
// Stacks are deployed in dependency order automatically
const dbStack = new McpDatabaseStack(this, 'Database', {
env: props?.env,
});
new McpServerStack(this, 'Server', {
env: props?.env,
table: dbStack.table, // cross-stack reference within Stage is fine
});
}
}
Cross-stack references within a Stage are fine — CDK handles CloudFormation export/import automatically within the same Stage account+region. Cross-stage references (passing a resource from Dev to Prod) are not supported and not recommended — each stage should be independently deployable.
Bootstrap requirements for cross-account deployment
CDK Pipelines uses cross-account CloudFormation deployment roles. The pipeline account assumes IAM roles in target accounts to deploy stacks. You must bootstrap every target account with --trust pointing to the pipeline account ID.
# Bootstrap the pipeline account first (no --trust needed for itself)
npx cdk bootstrap aws://PIPELINE_ACCOUNT/us-east-1
# Bootstrap dev target account, trusting the pipeline account to assume roles
npx cdk bootstrap aws://DEV_ACCOUNT/us-east-1 \
--trust PIPELINE_ACCOUNT \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
# Bootstrap prod target account
npx cdk bootstrap aws://PROD_ACCOUNT/us-east-1 \
--trust PIPELINE_ACCOUNT \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess
The --cloudformation-execution-policies flag controls what CloudFormation can do in the target account during deployment. AdministratorAccess is used for initial setup; for production accounts, scope it down to a custom policy that only permits the resources your MCP server stacks actually create (ECS, RDS, IAM roles, Secrets Manager, etc.).
If the pipeline account itself is a target (deploying to the same account as the pipeline), you still need to run cdk bootstrap without --trust in that account.
CodeBuildStep for Docker builds and integration tests
Use CodeBuildStep instead of ShellStep when you need Docker builds (privileged mode), custom CodeBuild environments, or additional IAM permissions for the CodeBuild project.
import { CodeBuildStep, CodeBuildOptions } from 'aws-cdk-lib/pipelines';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as iam from 'aws-cdk-lib/aws-iam';
const pipeline = new CodePipeline(this, 'Pipeline', {
pipelineName: 'McpServerPipeline',
dockerEnabledForSynth: true, // needed if Dockerfile asset in synth
synth: new CodeBuildStep('Synth', {
input: CodePipelineSource.gitHub('your-org/mcp-server', 'main', {
authentication: cdk.SecretValue.secretsManager('github-token'),
}),
buildEnvironment: {
buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
privileged: true, // needed for docker build in synth
computeType: codebuild.ComputeType.MEDIUM,
},
commands: [
'npm ci',
'npm run lint',
'npm run test',
'npx cdk synth',
],
rolePolicyStatements: [
new iam.PolicyStatement({
actions: ['ecr:GetAuthorizationToken', 'ecr:BatchGetImage'],
resources: ['*'],
}),
],
}),
});
Post-deployment integration tests use ShellStep in the stage's post array. The step receives the stage's stack outputs via envFromCfnOutputs:
import { ShellStep } from 'aws-cdk-lib/pipelines';
const devStage = pipeline.addStage(new McpServerStage(this, 'Dev', {
env: { account: '111111111111', region: 'us-east-1' },
}), {
post: [
new ShellStep('IntegTest', {
commands: [
'npm ci',
// Hit the actual deployed MCP server health endpoint
'curl -f $MCP_URL/health',
'npm run test:integ',
],
envFromCfnOutputs: {
MCP_URL: devStage.stage.mcpServiceUrl, // CfnOutput from McpServerStack
},
}),
],
});
The envFromCfnOutputs map resolves CloudFormation stack outputs into environment variables in the post-step CodeBuild job — the integration test step runs after the stage deploys and has the actual deployed URL available.
Waves for parallel stage deployment
Add multiple independent stages to a wave to deploy them in parallel. This is useful when you have multiple regional deployments of an MCP server that have no cross-dependency.
const multiRegionWave = pipeline.addWave('MultiRegion');
multiRegionWave.addStage(new McpServerStage(this, 'ProdUsEast1', {
env: { account: '222222222222', region: 'us-east-1' },
}));
multiRegionWave.addStage(new McpServerStage(this, 'ProdEuWest1', {
env: { account: '222222222222', region: 'eu-west-1' },
}));
multiRegionWave.addStage(new McpServerStage(this, 'ProdApSoutheast1', {
env: { account: '222222222222', region: 'ap-southeast-1' },
}));
All three regional stages deploy simultaneously within the wave. If any one fails, CodePipeline marks the wave as failed and stops. The ManualApprovalStep can be placed in the wave's pre array to gate all three regions with a single approval.
Customizing the pipeline CodeBuild environment
The pipeline's self-mutation step and deploy steps use CodeBuild projects. You can customize these via codeBuildDefaults on the CodePipeline construct — useful for adding a VPC (required if your CDK lookups need to reach private endpoints), custom IAM policies, or build images.
import * as ec2 from 'aws-cdk-lib/aws-ec2';
const vpc = ec2.Vpc.fromLookup(this, 'Vpc', { vpcId: 'vpc-0abc123' });
const pipeline = new CodePipeline(this, 'Pipeline', {
pipelineName: 'McpServerPipeline',
codeBuildDefaults: {
// All CodeBuild projects in the pipeline run inside this VPC
vpc,
// Additional IAM policy for all CodeBuild projects
rolePolicy: [
new iam.PolicyStatement({
actions: ['ssm:GetParameter'],
resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter/mcp/*`],
}),
],
buildEnvironment: {
computeType: codebuild.ComputeType.MEDIUM,
},
},
synth: new ShellStep('Synth', {
input: CodePipelineSource.gitHub('your-org/mcp-server', 'main', {
authentication: cdk.SecretValue.secretsManager('github-token'),
}),
commands: ['npm ci', 'npx cdk synth'],
}),
});
assetPublishingCodeBuildDefaults and synthCodeBuildDefaults override codeBuildDefaults for the asset-publishing and synth steps specifically — this is how you give the synth project privileged mode (Docker) while keeping the other projects non-privileged.
Pipeline notifications
Add SNS notifications for pipeline state changes using the underlying CodePipeline L2 construct, which CDK Pipelines exposes after pipeline.buildPipeline() is called.
import * as sns from 'aws-cdk-lib/aws-sns';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as codestar from 'aws-cdk-lib/aws-codestarnotifications';
// Force pipeline to build so we can access the underlying L2 construct
pipeline.buildPipeline();
const topic = new sns.Topic(this, 'PipelineNotifications');
new codestar.NotificationRule(this, 'PipelineAlerts', {
source: pipeline.pipeline, // .pipeline is the underlying L2 CodePipeline
events: [
'codepipeline-pipeline-pipeline-execution-failed',
'codepipeline-pipeline-pipeline-execution-succeeded',
'codepipeline-pipeline-manual-approval-needed',
],
targets: [topic],
notificationRuleName: 'McpPipelineAlerts',
});
Call pipeline.buildPipeline() explicitly before accessing pipeline.pipeline — CDK Pipelines lazily builds the underlying CodePipeline during synthesis, so you must trigger it early to interact with the L2 construct in the same CDK app pass.
Common failures
| Symptom | Cause | Fix |
|---|---|---|
| Synth step fails: "Need to perform AWS calls" | cdk.context.json not committed — CDK tries to look up VPC/AZ values during synth but has no live AWS access | Run cdk synth locally (with AWS credentials) and commit the generated cdk.context.json |
| Cross-account deploy fails: "is not authorized to assume role" | Target account not bootstrapped with --trust pointing to the pipeline account | Run cdk bootstrap aws://TARGET/REGION --trust PIPELINE_ACCOUNT in the target account |
| Pipeline self-mutation creates infinite loop | Pipeline stack has a resource that changes on every synth (e.g., a timestamp in a resource name) | Remove non-deterministic values from pipeline stack resources; pipeline stack must be idempotent |
| Asset publishing fails: "no space left on device" | Large Docker images exhaust CodeBuild ephemeral storage (default 25 GB) | Set assetPublishingCodeBuildDefaults: { buildEnvironment: { computeType: ComputeType.LARGE } } for more storage |
| IntegTest step can't reach deployed resource | Stack output not exported or envFromCfnOutputs key mismatch | Add new cdk.CfnOutput(this, 'McpUrl', { value: service.loadBalancerDnsName }) to stack; reference stage.outputs.McpUrl in envFromCfnOutputs |
| Manual approval email not sent | No email subscription on the SNS topic CodePipeline uses by default; or IAM policy blocks SNS publish | Add an email subscription manually in the console or use CodeStar Notifications with an SNS topic |