Guide · AWS CloudFormation · Custom Resources

CloudFormation Custom Resources for MCP Server Infrastructure

CloudFormation custom resources let you provision anything a Lambda function can reach — an MCP server endpoint registered in a discovery service, a DNS record in a provider CloudFormation doesn't support natively, a database seed record, or a Slack webhook channel. The handler receives a CloudFormation lifecycle event (Create, Update, or Delete) via an HTTP callback URL, performs the operation, and signals back with cfnresponse.send(). Three critical traps: never throwing without sending a response first — an unhandled exception silently skips the callback and CloudFormation waits one hour before marking the resource failed; the physical resource ID contract — if your Update handler returns a different physical ID than Create returned, CloudFormation sends a second Delete event for the old ID; idempotency on retries — CloudFormation retries failed Create calls, so your handler must check whether the resource already exists before creating it.

TL;DR

Always wrap the handler body in a try/finally and call cfnresponse.send() in the finally block. Design a stable physical resource ID (not a random UUID per invocation). Check for existence before creating in Create events. Return the same physical ID on Update unless you intend a replacement. Set Lambda timeout to under 15 minutes and less than the CloudFormation resource timeout.

Handler contract

CloudFormation sends a signed PUT request to an S3 pre-signed URL when the operation completes. The cfnresponse module constructs and sends that PUT. The event shape:

// Event fields for all request types
interface CloudFormationCustomResourceEvent {
  RequestType: "Create" | "Update" | "Delete";
  ResponseURL: string;          // pre-signed S3 URL — cfnresponse sends here
  StackId: string;
  RequestId: string;
  ResourceType: string;         // "Custom::McpEndpointRegistration"
  LogicalResourceId: string;    // resource name in the template
  PhysicalResourceId?: string;  // absent on Create; present on Update and Delete
  ResourceProperties: Record<string, string>;     // your custom properties
  OldResourceProperties?: Record<string, string>; // present only on Update
}

// Minimal handler skeleton — never let an exception escape without sending cfnresponse
import cfnresponse from "cfn-response";

export const handler = async (
  event: CloudFormationCustomResourceEvent,
  context: AWSLambda.Context
): Promise<void> => {
  let physicalResourceId = event.PhysicalResourceId ?? "";
  const responseData: Record<string, string> = {};

  try {
    if (event.RequestType === "Create") {
      physicalResourceId = await handleCreate(event.ResourceProperties);
    } else if (event.RequestType === "Update") {
      physicalResourceId = await handleUpdate(
        event.PhysicalResourceId!,
        event.ResourceProperties,
        event.OldResourceProperties!
      );
    } else if (event.RequestType === "Delete") {
      await handleDelete(event.PhysicalResourceId!);
    }

    await cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData, physicalResourceId);
  } catch (err) {
    console.error("Custom resource handler failed:", err);
    await cfnresponse.send(event, context, cfnresponse.FAILED, {
      Error: String(err),
    }, physicalResourceId);
  }
};

The cfnresponse package (npm install cfn-response) handles constructing the JSON body and sending the HTTPS PUT. Without it, you would manually fetch(event.ResponseURL, { method: "PUT", body: JSON.stringify(responseBody) }). If the Lambda function times out before sending the response, CloudFormation receives no signal and waits the full resource timeout (default 60 minutes) before marking the resource CREATE_FAILED.

Physical resource ID design

The physical resource ID uniquely identifies the resource instance across its lifetime. The most important invariant: your Update handler must return the same physical ID as Create returned for in-place updates. If it returns a different value, CloudFormation treats this as a replacement: it calls Delete on the old ID after the update succeeds.

// WRONG — random UUID per invocation causes replacement on every Update
async function handleCreate(props: Record<string, string>): Promise<string> {
  const id = randomUUID();   // NEW ID every Create and Update retry — breaks Update contract
  await registerEndpoint(id, props.endpoint);
  return id;
}

// CORRECT — deterministic ID derived from stable inputs
async function handleCreate(props: Record<string, string>): Promise<string> {
  // Derive ID from the resource's identity, not from a random value
  const id = `mcp-endpoint-${props.serviceName}-${props.environment}`;

  // Idempotency: check before creating
  const existing = await getEndpointById(id);
  if (existing) {
    console.log(`Endpoint ${id} already exists — returning existing ID (retry)`);
    return id;
  }

  await registerEndpoint(id, props.endpoint);
  return id;
}

// Update — return the SAME physical ID to signal in-place update
async function handleUpdate(
  physicalId: string,
  newProps: Record<string, string>,
  _oldProps: Record<string, string>
): Promise<string> {
  await updateEndpoint(physicalId, newProps.endpoint);
  return physicalId;   // same ID = in-place update, no Delete sent to old ID
}

// Delete — succeed even if resource is already gone (idempotency)
async function handleDelete(physicalId: string): Promise<void> {
  try {
    await deregisterEndpoint(physicalId);
  } catch (err: any) {
    if (err.code === "EndpointNotFound") return;  // already deleted — succeed
    throw err;
  }
}

Timeout architecture

CloudFormation waits up to the ServiceTimeout for a response (default 3600 seconds). The Lambda timeout must be short enough that the Lambda returns (even on failure) before CloudFormation times out. Recommended: set Lambda timeout to 300 seconds (5 minutes) and configure your async operations to complete within 240 seconds, leaving 60 seconds of buffer to send cfnresponse.

# CloudFormation template — custom resource with explicit timeout
McpEndpointRegistration:
  Type: Custom::McpEndpointRegistration
  Properties:
    ServiceToken: !GetAtt McpRegistrationFunction.Arn
    ServiceName: !Ref ServiceName
    Endpoint: !Sub "https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/v1"
    Environment: !Ref Environment
    # ServiceTimeout: 300  # optional — default is 3600s

# Lambda function — timeout must be less than ServiceTimeout
McpRegistrationFunction:
  Type: AWS::Lambda::Function
  Properties:
    Timeout: 300     # 5 minutes — leave buffer for cfnresponse
    MemorySize: 256
    Runtime: nodejs22.x
    Handler: index.handler
    Code:
      ZipFile: |
        # ... handler code here

For operations that take longer than 5 minutes (certificate issuance, large database migrations), use the stabilization pattern: a State Machine or separate Lambda polls the operation status and calls cfnresponse.send only when the operation is complete. The CDK cr.Provider framework implements this automatically using a Step Functions Express Workflow for long-running operations.

CDK Provider framework

The CDK cr.Provider from aws-cdk-lib/custom-resources wraps the Lambda boilerplate and handles stabilization via an included Step Functions state machine for operations that take longer than 15 minutes:

import * as cr from "aws-cdk-lib/custom-resources";
import * as lambda from "aws-cdk-lib/aws-lambda";

// Handler Lambda — same event contract as raw custom resource
const handlerFn = new lambda.Function(this, "McpRegistrationHandler", {
  runtime: lambda.Runtime.NODEJS_22_X,
  handler: "index.handler",
  code: lambda.Code.fromAsset("./lambda/mcp-registration"),
  timeout: cdk.Duration.minutes(5),
});

// Provider wraps the handler and handles signaling
const provider = new cr.Provider(this, "McpRegistrationProvider", {
  onEventHandler: handlerFn,
  // For async operations that need polling:
  // isCompleteHandler: isCompleteFn,
  // queryInterval: cdk.Duration.seconds(30),
  // totalTimeout: cdk.Duration.minutes(30),
});

// Custom resource — properties are passed to the handler as ResourceProperties
const registration = new cdk.CustomResource(this, "McpRegistration", {
  serviceToken: provider.serviceToken,
  properties: {
    ServiceName: "mcp-tool-server",
    Endpoint: `https://${api.restApiId}.execute-api.${this.region}.amazonaws.com/prod`,
    Environment: props.environment,
  },
  removalPolicy: cdk.RemovalPolicy.DESTROY,  // triggers Delete on stack removal
});

// Reference outputs from the custom resource
const registrationId = registration.getAttString("RegistrationId");

cr.Provider eliminates the need to install and call cfn-response manually — the framework handles response signaling. The isCompleteHandler pattern is for operations where the primary Lambda kicks off an async process and a second Lambda polls until IsComplete: true.

Returning values from custom resources

The responseData object passed to cfnresponse.send() becomes available as custom resource attributes in CloudFormation. Reference them with !GetAtt LogicalId.AttributeName or in CDK with customResource.getAttString("Key"):

// In the Lambda handler — include values in responseData
const responseData = {
  RegistrationId: newRegistrationId,
  HealthCheckUrl: `https://api.registry.example.com/health/${newRegistrationId}`,
  Region: process.env.AWS_REGION!,
};

await cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData, physicalResourceId);

// In the CloudFormation template — reference the output attribute
McpServiceUrl:
  Value: !GetAtt McpEndpointRegistration.HealthCheckUrl
  Export:
    Name: !Sub "${AWS::StackName}-McpHealthCheckUrl"

# Or reference in another resource
OtherResource:
  Type: AWS::SSM::Parameter
  Properties:
    Name: /mcp/registration-id
    Value: !GetAtt McpEndpointRegistration.RegistrationId

Attribute values must be strings or numbers — complex objects are not supported. Flatten nested objects into individual string attributes before including in responseData. Values larger than 4,096 bytes should be written to S3 or SSM Parameter Store and the attribute should contain the reference path.

Monitor your CloudFormation-deployed MCP endpoints

Custom resources provision the infrastructure, but they don't tell you when an MCP server goes down after deployment. AliveMCP probes every MCP tool endpoint every 60 seconds and alerts your team before users notice a failure — no CloudFormation custom resource required.

Join the waitlist →