Guide · AWS CDK · Aspects · Policy-as-Code

CDK Aspects for MCP Server Policy-as-Code

CDK Aspects implement the visitor pattern over the entire construct tree — every construct that matches a predicate gets a callback during synthesis, before the CloudFormation template is generated. For MCP server infrastructure, Aspects are the right place for cross-cutting concerns: enforce that every S3 bucket has encryption enabled, that every Lambda function has a dead-letter queue, that every ECS task definition has log configuration set. The rule is applied at synthesis time and fails the build before any CloudFormation template is deployed. Three Aspect use cases matter most: security enforcement — fail synthesis if a resource violates a policy (public S3 bucket, unencrypted RDS, Lambda with overly permissive timeout); automated tagging — add cost-center, team, and environment tags to every taggable resource without touching individual constructs; cdk-nag — the AWS Solutions CDK Nag library applies 200+ pre-built compliance checks (AWS Best Practices, HIPAA, PCI-DSS) as Aspects in a single call.

TL;DR

Implement IAspect with a visit(node: IConstruct) method. Guard with if (node instanceof s3.Bucket). Call Aspects.of(scope).add(new MyAspect()) at the App or Stack level. Use Annotations.of(node).addError() to fail synthesis. Use Tags.of(scope).add("team", "platform") for bulk tagging without a custom Aspect.

Implementing a security enforcement Aspect

A concrete Aspect that enforces encryption-at-rest on all S3 buckets and DynamoDB tables in the stack:

import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import { IConstruct } from "constructs";

class McpSecurityAspect implements cdk.IAspect {
  visit(node: IConstruct): void {
    // Enforce S3 encryption
    if (node instanceof s3.Bucket) {
      const cfnBucket = node.node.defaultChild as s3.CfnBucket;
      if (!cfnBucket.bucketEncryption) {
        cdk.Annotations.of(node).addError(
          `S3 bucket '${node.node.path}' must have encryption configured. ` +
          `Add encryption: s3.BucketEncryption.S3_MANAGED or KMS.`
        );
      }
      // Also ensure public access is blocked
      if (!node.node.tryFindChild("PublicAccessBlockConfiguration")) {
        cdk.Annotations.of(node).addWarning(
          `S3 bucket '${node.node.path}' does not have public access blocked. ` +
          `Add blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL`
        );
      }
    }

    // Enforce DynamoDB point-in-time recovery for production tables
    if (node instanceof dynamodb.Table) {
      const cfnTable = node.node.defaultChild as dynamodb.CfnTable;
      const pitrEnabled =
        cfnTable.pointInTimeRecoverySpecification !== undefined &&
        (cfnTable.pointInTimeRecoverySpecification as any).pointInTimeRecoveryEnabled === true;
      if (!pitrEnabled) {
        cdk.Annotations.of(node).addError(
          `DynamoDB table '${node.node.path}' must have PITR enabled. ` +
          `Add pointInTimeRecovery: true`
        );
      }
    }
  }
}

// Apply to the entire stack — runs on every construct during synthesis
const app = new cdk.App();
const stack = new McpServerStack(app, "McpServerProd");
cdk.Aspects.of(stack).add(new McpSecurityAspect());

addError() fails the CDK synthesis with a non-zero exit code — the CloudFormation template is not generated and the deployment cannot proceed. addWarning() prints a warning but does not block synthesis. addInfo() is informational only. Use addError() for hard requirements (encryption, no public access), addWarning() for best practices that have known exceptions.

Automated tagging with Tags.of()

CDK's built-in tagging uses an Aspect internally. Tags.of(scope).add(key, value) adds a tag to every taggable construct in the scope tree:

// Tag the entire app — all resources get these tags
const app = new cdk.App();
Tags.of(app).add("managed-by", "cdk");
Tags.of(app).add("repo", "github.com/org/mcp-server");

// Tag a specific stack
const stack = new McpServerStack(app, "McpServerProd", { env: prodEnv });
Tags.of(stack).add("environment", "production");
Tags.of(stack).add("cost-center", "platform");
Tags.of(stack).add("team", "mcp-infra");
Tags.of(stack).add("service", "alivemcp");

// Exclude specific resources from a tag (priority: higher wins)
const logsBucket = new s3.Bucket(stack, "LogsBucket", { ... });
Tags.of(logsBucket).remove("cost-center");   // remove inherited tag
Tags.of(logsBucket).add("cost-center", "shared-infra", { priority: 200 });

// Custom Aspect for conditional tagging — tag Lambda functions with their runtime
class RuntimeTagAspect implements cdk.IAspect {
  visit(node: IConstruct): void {
    if (node instanceof lambda.Function) {
      const cfnFn = node.node.defaultChild as lambda.CfnFunction;
      if (cfnFn.runtime) {
        cdk.Tags.of(node).add("runtime", String(cfnFn.runtime));
      }
    }
  }
}

cdk.Aspects.of(stack).add(new RuntimeTagAspect());

Tag propagation through CloudFormation resource types varies — not all resource types support AWS tags. CDK's Tags.of() silently skips non-taggable constructs. Check AWS documentation for the specific resource type if tags are not appearing in the Console.

cdk-nag — pre-built compliance Aspects

The cdk-nag library (from AWS Solutions) implements 200+ security and compliance rules as CDK Aspects. Install once, add to the App, and get a compliance report at synthesis time:

import { AwsSolutionsChecks, NagSuppressions, HIPAASecurityChecks } from "cdk-nag";

// Apply AWS Solutions checks to the entire app
// Runs ~150 rules covering encryption, IAM least privilege, logging, etc.
cdk.Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true }));

// For regulated industries — HIPAA compliance checks
// cdk.Aspects.of(app).add(new HIPAASecurityChecks());

// Suppress specific rules with required justification (creates audit trail)
NagSuppressions.addResourceSuppressions(
  stack.mcpServerRole,
  [
    {
      id: "AwsSolutions-IAM4",
      reason: "AWSLambdaBasicExecutionRole is required for Lambda CloudWatch logging — this is the minimal managed policy for Lambda execution",
    }
  ],
  true  // apply to children of this construct
);

// Suppress at stack level for a specific rule
NagSuppressions.addStackSuppressions(stack, [
  {
    id: "AwsSolutions-S1",
    reason: "Server access logging is disabled on the artifacts bucket — this bucket receives no sensitive data and logging would create recursive log growth",
  }
]);

// Common cdk-nag rules relevant to MCP servers:
// AwsSolutions-IAM5  — wildcard permissions (e.g., s3:* or lambda:*)
// AwsSolutions-L1   — non-latest Lambda runtime (e.g., nodejs18.x vs nodejs22.x)
// AwsSolutions-SQS3 — SQS queue without DLQ
// AwsSolutions-EC23 — security group with 0.0.0.0/0 inbound
// AwsSolutions-DDB3 — DynamoDB table without PITR
// AwsSolutions-RDS2 — RDS instance without storage encryption
// AwsSolutions-CFR3 — CloudFront without access logging

Every suppression requires a reason string — this creates a documented audit trail of intentional policy exceptions. Without reason, the suppression is rejected. In CI/CD, cdk-nag failures appear as synthesis errors and block deployment.

Escape hatches — modifying CloudFormation properties CDK doesn't expose

Aspects can also mutate constructs through escape hatches when the CDK construct doesn't expose a property you need. Access the underlying CfnResource via node.defaultChild:

class McpLambdaMutationAspect implements cdk.IAspect {
  visit(node: IConstruct): void {
    if (node instanceof lambda.Function) {
      const cfnFn = node.node.defaultChild as lambda.CfnFunction;

      // Set SnapStart — not available in CDK L2 construct as of CDK v2.100
      cfnFn.addPropertyOverride("SnapStart", {
        ApplyOn: "PublishedVersions",
      });

      // Set RecursionDetectionConfig — CDK doesn't expose this yet
      cfnFn.addPropertyOverride("RecursiveLoop", "Terminate");

      // Ensure logging config is set (CDK does not set it by default)
      if (!cfnFn.loggingConfig) {
        cfnFn.addPropertyOverride("LoggingConfig", {
          LogFormat: "JSON",
          SystemLogLevel: "WARN",
          ApplicationLogLevel: "INFO",
        });
      }
    }

    // Enforce ECS task definitions have log configuration
    if (node instanceof ecs.ContainerDefinition) {
      if (!node.logDriverConfig) {
        cdk.Annotations.of(node).addError(
          `ECS container '${node.node.path}' has no log configuration. ` +
          `Add logging: ecs.LogDrivers.awsLogs({ streamPrefix: "mcp" })`
        );
      }
    }
  }
}

cdk.Aspects.of(stack).add(new McpLambdaMutationAspect());

addPropertyOverride uses JSON Pointer path syntax (e.g., "LoggingConfig/LogFormat" for nested properties). addOverride operates on the full CloudFormation resource path. Escape hatches bypass CDK's validation — use them only when the L2 construct genuinely doesn't support what you need.

CDK Pipelines — self-mutating GitOps deployment

CDK Pipelines creates a CodePipeline that deploys your CDK app and updates the pipeline itself when you change the pipeline definition. This is the GitOps pattern for CDK: push to main, pipeline updates itself, then deploys the new application version:

import * as pipelines from "aws-cdk-lib/pipelines";

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

    const pipeline = new pipelines.CodePipeline(this, "McpPipeline", {
      pipelineName: "McpServerPipeline",
      synth: new pipelines.ShellStep("Synth", {
        input: pipelines.CodePipelineSource.gitHub("org/mcp-server", "main"),
        commands: [
          "npm ci",
          "npm run build",
          "npx cdk synth",
        ],
      }),
      codeBuildDefaults: {
        buildEnvironment: {
          buildImage: codebuild.LinuxBuildImage.STANDARD_7_0,
        },
        rolePolicy: [
          // Add extra permissions for synth if needed
          new iam.PolicyStatement({
            actions: ["secretsmanager:GetSecretValue"],
            resources: [`arn:aws:secretsmanager:${this.region}:${this.account}:secret:mcp/build-*`],
          }),
        ],
      },
    });

    // Staging stage — deploy and run integration tests
    const staging = new McpServerStage(this, "Staging", { env: stagingEnv });
    const stagingStage = pipeline.addStage(staging);
    stagingStage.addPost(
      new pipelines.ShellStep("IntegrationTest", {
        commands: [
          "npm run test:integration",
        ],
        envFromCfnOutputs: {
          MCP_API_URL: staging.apiUrl,
        },
      })
    );

    // Production stage — manual approval required
    const production = new McpServerStage(this, "Production", { env: prodEnv });
    pipeline.addStage(production, {
      pre: [
        new pipelines.ManualApprovalStep("ApproveProductionDeploy"),
      ],
    });
  }
}

Self-mutation means the pipeline's first action on every run is to deploy any changes to the pipeline definition itself. After self-mutation, the pipeline restarts. This ensures the deploy pipeline always reflects the committed pipeline code — no separate pipeline-update step required.

Monitor MCP servers deployed via CDK Pipelines

CDK Aspects enforce policies at synthesis time and CDK Pipelines automate deployment, but neither alerts you when a deployed MCP server goes down in production. AliveMCP probes every MCP endpoint every 60 seconds and pages your team immediately on failure.

Join the waitlist →