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 →