Guide · AWS CDK · Custom Resources

MCP Server CDK Custom Resources — AwsCustomResource, Provider Framework

CDK custom resources let you run arbitrary code during CloudFormation stack create, update, and delete operations — filling the gap between what CloudFormation L1 constructs support natively and what your MCP server deployment actually needs. Two tools cover almost every case: AwsCustomResource (from aws-cdk-lib/custom-resources) is the right choice when you just need to call a single AWS SDK method and store the response — it creates a Lambda-backed CloudFormation custom resource for you without writing a Lambda function; the Provider framework (cr.Provider) is the right choice when you need multi-step async provisioning, polling for completion, or complex cleanup logic — you supply your own onEventHandler Lambda and optionally an isCompleteHandler Lambda for async workflows. The critical rule for both: always set a stable PhysicalResourceId — if the physical ID changes between updates, CloudFormation treats it as a delete-then-create, running your onDelete handler on the old resource and onCreate on the "new" one.

TL;DR

Use AwsCustomResource for inline AWS SDK calls (enable a service feature, fetch a value, register an endpoint). Use cr.Provider when you need custom logic, retries, or async polling. Always specify PhysicalResourceId.of('stable-string') so updates don't accidentally trigger delete-and-recreate. Grant the custom resource's Lambda role only the specific IAM actions it calls.

AwsCustomResource — inline AWS SDK calls

AwsCustomResource takes three optional AwsSdkCall descriptors — onCreate, onUpdate, onDelete — and creates a single Lambda function that dispatches to the right SDK call based on the CloudFormation event type. This is ideal for provisioning tasks that map to a single API call.

import * as cdk from 'aws-cdk-lib';
import * as cr from 'aws-cdk-lib/custom-resources';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';

// Example: enable SES account-level suppression list on stack creation
export class SesSuppressionListEnabler extends Construct {
  constructor(scope: Construct, id: string) {
    super(scope, id);

    new cr.AwsCustomResource(this, 'EnableSuppression', {
      onCreate: {
        service: 'SESv2',
        action: 'PutAccountSuppressionAttributes',
        parameters: {
          SuppressedReasons: ['BOUNCE', 'COMPLAINT'],
        },
        // Physical ID must be stable — use a fixed string when the resource
        // has no server-assigned ID (enabling an account-level toggle)
        physicalResourceId: cr.PhysicalResourceId.of('ses-suppression-list-enabled'),
      },
      onDelete: {
        service: 'SESv2',
        action: 'PutAccountSuppressionAttributes',
        parameters: {
          SuppressedReasons: [],  // disable on stack delete
        },
        physicalResourceId: cr.PhysicalResourceId.of('ses-suppression-list-enabled'),
      },
      policy: cr.AwsCustomResourcePolicy.fromStatements([
        new iam.PolicyStatement({
          actions: ['ses:PutAccountSuppressionAttributes'],
          resources: ['*'],
        }),
      ]),
    });
  }
}

The service field matches the AWS SDK v3 client name without the Client suffix ('SESv2' maps to SESv2Client). The action field is the command class name without the Command suffix ('PutAccountSuppressionAttributes' maps to PutAccountSuppressionAttributesCommand).

Reading output values from AwsCustomResource

The physicalResourceId can capture a response field from the AWS SDK call — this is the mechanism for reading a value that only exists after creation (like a generated ID or ARN) and passing it to other constructs in the same stack.

// Create an MCP server registration in a hypothetical service
// and capture the assigned registration ID from the response
const registration = new cr.AwsCustomResource(this, 'McpRegistration', {
  onCreate: {
    service: 'SSM',
    action: 'PutParameter',
    parameters: {
      Name: '/mcp/server/endpoint',
      Value: mcpService.loadBalancerDnsName,
      Type: 'String',
      Overwrite: true,
    },
    // Capture the Version field from the PutParameter response
    physicalResourceId: cr.PhysicalResourceId.fromResponse('Version'),
  },
  onUpdate: {
    service: 'SSM',
    action: 'PutParameter',
    parameters: {
      Name: '/mcp/server/endpoint',
      Value: mcpService.loadBalancerDnsName,
      Type: 'String',
      Overwrite: true,
    },
    physicalResourceId: cr.PhysicalResourceId.fromResponse('Version'),
  },
  onDelete: {
    service: 'SSM',
    action: 'DeleteParameter',
    parameters: { Name: '/mcp/server/endpoint' },
    physicalResourceId: cr.PhysicalResourceId.of('mcp-registration-cleanup'),
  },
  policy: cr.AwsCustomResourcePolicy.fromStatements([
    new iam.PolicyStatement({
      actions: ['ssm:PutParameter', 'ssm:DeleteParameter'],
      resources: [`arn:aws:ssm:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:parameter/mcp/*`],
    }),
  ]),
});

// Read a field from the AwsCustomResource response (after-creation read call)
const paramVersion = registration.getResponseField('Version');

getResponseField('Version') returns a CDK token that resolves to the specified field from the last SDK response. For PhysicalResourceId.fromResponse('Version'), note that if the version changes on update (it will — SSM increments it), the physical ID changes too, which triggers a CloudFormation "replacement" event. Use PhysicalResourceId.of('fixed-string') when you don't want replacement semantics.

Fetching existing resource data with AwsCustomResource

A common pattern is using AwsCustomResource as a lookup — reading a value from an existing AWS resource that isn't a CloudFormation output or CDK token. For example, fetching the current value of an SSM parameter that was set by another team's stack.

// Fetch an SSM parameter value from another team's stack
const mcpConfigParam = new cr.AwsCustomResource(this, 'FetchMcpConfig', {
  onCreate: {
    service: 'SSM',
    action: 'GetParameter',
    parameters: {
      Name: '/shared/mcp/registry-url',
      WithDecryption: true,
    },
    physicalResourceId: cr.PhysicalResourceId.of('mcp-registry-url-lookup'),
  },
  // onUpdate: same call — re-fetch on every stack update to pick up changes
  onUpdate: {
    service: 'SSM',
    action: 'GetParameter',
    parameters: {
      Name: '/shared/mcp/registry-url',
      WithDecryption: true,
    },
    physicalResourceId: cr.PhysicalResourceId.of('mcp-registry-url-lookup'),
  },
  policy: cr.AwsCustomResourcePolicy.fromStatements([
    new iam.PolicyStatement({
      actions: ['ssm:GetParameter'],
      resources: [`arn:aws:ssm:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:parameter/shared/mcp/*`],
    }),
  ]),
});

const registryUrl = mcpConfigParam.getResponseField('Parameter.Value');

Dot notation in getResponseField navigates nested response objects — 'Parameter.Value' reaches response.Parameter.Value from the SSM GetParameterResult. The CDK token returned is usable anywhere a string token is accepted (ECS environment variable, Lambda env var, etc.).

Provider framework — complex custom resources

The cr.Provider framework is for cases where AwsCustomResource falls short: you need multiple SDK calls, error handling, polling for async completion, or logic that doesn't map to a single AWS API. You supply a Lambda function as onEventHandler and optionally a second Lambda as isCompleteHandler for async workflows.

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

// Lambda that handles CREATE/UPDATE/DELETE events for a custom MCP registry entry
const onEventHandler = new lambda.Function(this, 'RegistryOnEvent', {
  runtime: lambda.Runtime.NODEJS_20_X,
  handler: 'index.onEvent',
  code: lambda.Code.fromAsset('lambda/mcp-registry-custom-resource'),
  timeout: cdk.Duration.minutes(5),
  environment: {
    REGISTRY_TABLE: registryTable.tableName,
  },
});
registryTable.grantReadWriteData(onEventHandler);

// Optional: async completion polling Lambda
// Called every 10s until it returns IsComplete: true
const isCompleteHandler = new lambda.Function(this, 'RegistryIsComplete', {
  runtime: lambda.Runtime.NODEJS_20_X,
  handler: 'index.isComplete',
  code: lambda.Code.fromAsset('lambda/mcp-registry-custom-resource'),
  timeout: cdk.Duration.minutes(1),
  environment: {
    REGISTRY_TABLE: registryTable.tableName,
  },
});
registryTable.grantReadData(isCompleteHandler);

const provider = new cr.Provider(this, 'RegistryProvider', {
  onEventHandler,
  isCompleteHandler,  // omit if synchronous (no polling needed)
  queryInterval: cdk.Duration.seconds(10),
  totalTimeout: cdk.Duration.minutes(30),
});

// Use the provider's service token to create a CloudFormation custom resource
const registryEntry = new cdk.CustomResource(this, 'McpRegistryEntry', {
  serviceToken: provider.serviceToken,
  resourceType: 'Custom::McpRegistryEntry',  // must start with Custom::
  properties: {
    ServerName: 'my-mcp-server',
    EndpointUrl: mcpService.loadBalancerDnsName,
    Version: '1.0.0',
  },
});

The onEventHandler Lambda receives a CloudFormation custom resource event with RequestType ('Create', 'Update', or 'Delete'), ResourceProperties, and OldResourceProperties (for Update). It must return a PhysicalResourceId. If using isCompleteHandler, onEventHandler returns immediately after starting the async operation; isCompleteHandler is polled until it returns { IsComplete: true }.

onEventHandler Lambda contract

The Lambda function must follow the custom resource event/response contract. The key points: return PhysicalResourceId on Create (CDK auto-generates one if omitted but that's unreliable); never change the physical ID on Update unless you intend replacement; return Data as a flat map of string values that become accessible via Fn::GetAtt.

// lambda/mcp-registry-custom-resource/index.ts
import { DynamoDBClient, PutItemCommand, DeleteItemCommand, GetItemCommand } from '@aws-sdk/client-dynamodb';

const ddb = new DynamoDBClient({});
const TABLE = process.env.REGISTRY_TABLE!;

export async function onEvent(event: AWSCDKAsyncCustomResource.OnEventRequest) {
  const { RequestType, ResourceProperties, PhysicalResourceId } = event;
  const serverName = ResourceProperties.ServerName as string;
  const endpointUrl = ResourceProperties.EndpointUrl as string;

  if (RequestType === 'Create' || RequestType === 'Update') {
    await ddb.send(new PutItemCommand({
      TableName: TABLE,
      Item: {
        pk: { S: `server#${serverName}` },
        endpoint: { S: endpointUrl },
        version: { S: ResourceProperties.Version },
        updatedAt: { S: new Date().toISOString() },
      },
    }));
    return {
      // Always return the same PhysicalResourceId on Update —
      // changing it triggers delete of the old ID + create of the new one
      PhysicalResourceId: `registry-${serverName}`,
      Data: {
        RegistryKey: `server#${serverName}`,
        EndpointUrl: endpointUrl,
      },
    };
  }

  if (RequestType === 'Delete') {
    await ddb.send(new DeleteItemCommand({
      TableName: TABLE,
      Key: { pk: { S: `server#${serverName}` } },
    }));
    return { PhysicalResourceId };
  }

  throw new Error(`Unexpected RequestType: ${RequestType}`);
}

// isCompleteHandler — only needed for async resources
export async function isComplete(event: AWSCDKAsyncCustomResource.IsCompleteRequest) {
  const serverName = event.ResourceProperties.ServerName as string;
  const item = await ddb.send(new GetItemCommand({
    TableName: TABLE,
    Key: { pk: { S: `server#${serverName}` } },
  }));
  return {
    IsComplete: !!item.Item?.endpoint,
    Data: item.Item ? { EndpointUrl: item.Item.endpoint.S } : undefined,
  };
}

Reading outputs from a Provider-backed custom resource uses customResource.getAttString('RegistryKey') — the Data map keys become CloudFormation attribute names accessible via Fn::GetAtt.

Sharing a Provider across multiple custom resources

Creating a Provider deploys two Lambda functions and a Step Functions state machine — that's non-trivial per-resource overhead. When you need multiple custom resources of the same type in a stack, share one Provider instead of creating one per resource.

// shared-provider.ts — provider as a singleton construct
import * as cdk from 'aws-cdk-lib';
import * as cr from 'aws-cdk-lib/custom-resources';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

export class McpRegistryProvider extends Construct {
  public readonly serviceToken: string;

  constructor(scope: Construct, id: string) {
    super(scope, id);

    const handler = new lambda.Function(this, 'Handler', {
      runtime: lambda.Runtime.NODEJS_20_X,
      handler: 'index.onEvent',
      code: lambda.Code.fromAsset('lambda/mcp-registry-custom-resource'),
    });

    const provider = new cr.Provider(this, 'Provider', {
      onEventHandler: handler,
    });

    this.serviceToken = provider.serviceToken;
  }

  // Singleton pattern: reuse the same provider in a stack
  public static getOrCreate(scope: Construct): McpRegistryProvider {
    const stack = cdk.Stack.of(scope);
    const id = 'McpRegistryProvider';
    const existing = stack.node.tryFindChild(id) as McpRegistryProvider;
    return existing ?? new McpRegistryProvider(stack, id);
  }
}

// Usage in any construct in the same stack:
const provider = McpRegistryProvider.getOrCreate(this);
new cdk.CustomResource(this, 'Entry1', {
  serviceToken: provider.serviceToken,
  properties: { ServerName: 'mcp-server-a', EndpointUrl: serviceA.url },
});
new cdk.CustomResource(this, 'Entry2', {
  serviceToken: provider.serviceToken,
  properties: { ServerName: 'mcp-server-b', EndpointUrl: serviceB.url },
});

Common failures

SymptomCauseFix
Stack stuck in CREATE_FAILED or rollback for 1+ hourCustom resource Lambda timed out without sending a response — CloudFormation waits up to 1 hour before timing outAlways use try/catch and return a failure response explicitly; never let the Lambda crash silently
Update triggers unexpected delete-then-createPhysicalResourceId changed between Create and Update — CloudFormation interprets this as "delete the old resource, create a new one"Return the same PhysicalResourceId on Update unless you explicitly want replacement
getResponseField returns undefined tokenResponse field path is wrong or the field isn't in the AWS SDK responseLog the full SDK response in a test deploy and use exact dot-path from the response JSON
Provider Lambda can't access DynamoDB / other serviceLambda execution role missing IAM permissionsCall table.grantReadWriteData(handler) or add explicit PolicyStatement to handler.addToRolePolicy()
AwsCustomResource service/action not foundService name or action name doesn't match SDK v3 namingService = SDK client class without "Client" suffix; action = command class without "Command" suffix. Check AWS SDK v3 docs for the exact class names.