AWS API Gateway · 2026-11-02 · API Gateway Advanced arc

AWS API Gateway Advanced Patterns for MCP Servers: Usage Plans, Canary Deployments, Request Validation, and Custom Domains

AliveMCP probes every registered MCP endpoint over HTTPS every 60 seconds — and every one of those MCP endpoints, if built on AWS, likely sits behind API Gateway, meaning the deployment, access control, validation, and domain configuration of the gateway itself directly determines whether probes succeed or fail silently. Four advanced API Gateway capabilities close the gaps that a basic Lambda proxy integration leaves open. Usage plans solve the shared-fate throttling problem that causes a single misbehaving MCP client firing a tight retry loop to exhaust an entire account's 10,000 req/s budget: by associating each API key with its own throttle (rateLimit req/sec, burstLimit concurrent tokens) and hard quota (limit requests per DAY/WEEK/MONTH), operators can offer free-tier clients 10 req/sec and enterprise clients 1,000 req/sec from the same API stage, with zero custom instrumentation — CloudWatch automatically emits Count metrics broken down by KeyId dimension so you get per-client billing analytics without writing a line of telemetry code; the two error responses are not interchangeable and must be handled differently by clients: 429 Limit Exceeded means the calendar-period quota cap was reached and the client must wait for reset, while 429 Too Many Requests means the real-time burst limit was hit and exponential backoff with jitter is the correct response; method-level throttle overrides via update-stage patch operations let you protect expensive POST /mcp tool invocations with a 50 req/sec limit while keeping cheap GET /status endpoints at 1,000 req/sec — and usage plans are a REST API feature only, not available on HTTP API v2. Canary deployments solve the all-or-nothing production promotion problem by extending the stage model with a second deployment pointer — the stage simultaneously tracks a deploymentId (baseline, serving 95% of traffic) and a canarySettings.deploymentId (new version, serving 5%), routing requests via a weighted random decision before any request reaches Lambda; stage variable Lambda alias routing makes this even more powerful: when the integration URI uses ${stageVariables.lambdaAlias} instead of a hard-coded ARN, the baseline stage variable points to the stable alias and the canary stageVariableOverrides map points to the canary alias — so deploying a new Lambda version to canary requires only an alias update, not a new API Gateway deployment, which matters because the deployment limit is 1,000 per API; API Gateway marks every response from the canary path with the header X-Amzn-ApiGateway-Experimental: true, which AliveMCP uses to create two separate health timelines within a single endpoint monitor — baseline P99 and canary P99 — giving operators a clear visual signal to roll back before the canary percentage climbs if error rates diverge; rollback is a single call: delete-stage-canary instantly restores 100% traffic to baseline without any deployment. Request validation solves the Lambda invocation cost problem for public MCP endpoints that receive a constant stream of garbage from internet scanners and bots: the API Gateway RequestValidator evaluates incoming request bodies against a JSON Schema draft-04 model and checks required headers and query strings before invoking Lambda, meaning structurally invalid requests are rejected at the edge with a 400 Bad Request and Lambda is never called, never billed; at 10 million requests per day where 20% are malformed, that is $146/year in pure waste eliminated; the four-step setup is create-model, create-request-validator, update-method to attach both requestModels AND requestValidatorId (both required — either one alone does nothing), and create-deployment to push the changes to the stage; GatewayResponse customization for REQUEST_VALIDATION_ERROR using $context.error.validationErrorString replaces the unhelpful default {"message": "Invalid request body"} with a structured error that includes the specific field that failed and the schema rule that was violated; request validation is a REST API feature only. Custom domains solve the URL instability problem where the default execute-api.amazonaws.com hostname changes when the API is recreated: by mapping api.yourmcp.com to a regional API Gateway endpoint via an ACM certificate, a Route53 alias A record, and base path mappings that route /v1 and /v2 path prefixes to different API stages, operators decouple client-facing URLs from API IDs entirely; the regional endpoint is almost always the right choice over edge-optimized — it keeps the ACM certificate in the same region as the API, avoids the CloudFront us-east-1 certificate constraint, supports mutual TLS authentication, and lets you add CloudFront manually with full cache behavior control if needed later; the Route53 A record alias must point to the regionalDomainName from create-domain-name output (which starts with d-), not the execute-api.amazonaws.com URL — they are two completely different endpoints; and enterprise clients in financial services, healthcare, or government environments can be required to present client certificates via an mTLS truststore bundle in S3, with the critical operational detail that API Gateway does not poll the truststore for updates and you must call update-domain-name with the new S3 object version ID after every CA bundle change. This guide synthesises all four API Gateway advanced patterns. Pattern 1 covers usage plans — throttle vs quota mechanics, API key lifecycle, per-method overrides, GetUsage API, and CloudWatch per-key analytics — from API Gateway Usage Plans for MCP Servers. Pattern 2 covers canary deployments — the dual-deployment stage model, stage variable Lambda alias routing, 5-phase workflow, AliveMCP canary monitoring, and failure modes — from API Gateway Canary Deployments for MCP Servers. Pattern 3 covers request validation — JSON Schema model creation, RequestValidator attachment, parameter validation, GatewayResponse customization, and CDK setup — from API Gateway Request Validation for MCP Servers. Pattern 4 covers custom domains — regional vs edge-optimized decision, four-phase creation workflow, base path mapping versioning, mTLS truststore management, and CDK setup — from API Gateway Custom Domains for MCP Servers.

TL;DR

Pattern 1: Usage Plans — tier-based rate limiting and per-client billing analytics

The default API Gateway account-level throttle of 10,000 req/sec with a 5,000-request burst pool is shared across every API, every stage, and every client in the account. A single misbehaving MCP client — a sync loop gone wrong, a framework retrying too aggressively after a 500 — can exhaust this pool and cause 429 responses for all other clients simultaneously. Usage plans break this shared fate by assigning limits at the key level, enabling true multi-tenant isolation for MCP server products that offer tiered access.

Throttle vs quota: two different mechanisms, two different 429 messages

The most important concept in usage plans is that throttle and quota are completely independent limiting mechanisms, and the 429 errors they generate have different messages that require different client-side handling:

A typical MCP server product tier structure might look like this:

TierRate (req/sec)BurstDaily QuotaMonthly Price
Free10201,000$0
Starter10020010,000$29
Pro5001,000100,000$99
Enterprise1,0005,000No quotaCustom

Four-step API key and usage plan workflow

The setup has a specific order: create the usage plan with the stage association, create the API key, link them with a UsagePlanKey resource, and verify. Skipping the linking step (step 3) is the most common mistake — the key exists and is enabled, but requests return 403 Forbidden because no UsagePlanKey resource associates the key with a plan.

# Step 1: Create the usage plan (stage association included at creation)
aws apigateway create-usage-plan \
  --name "mcp-pro-tier" \
  --throttle rateLimit=500,burstLimit=1000 \
  --quota limit=100000,offset=0,period=DAY \
  --api-stages apiId=abc123def,stage=prod

USAGE_PLAN_ID=$(aws apigateway get-usage-plans \
  --query "items[?name=='mcp-pro-tier'].id" --output text)

# Step 2: Create an API key for a specific client
aws apigateway create-api-key \
  --name "client-acme-corp" \
  --enabled \
  --generate-distinct-id

KEY_ID=$(aws apigateway get-api-keys \
  --query "items[?name=='client-acme-corp'].id" --output text)

# Step 3: Link the key to the plan (creates UsagePlanKey — MUST NOT skip this)
aws apigateway create-usage-plan-key \
  --usage-plan-id $USAGE_PLAN_ID \
  --key-id $KEY_ID \
  --key-type API_KEY

# Step 4: Verify the association exists
aws apigateway get-usage-plan-keys --usage-plan-id $USAGE_PLAN_ID

Per-method throttle overrides for protecting expensive endpoints

The usage plan throttle applies to all methods on the associated stage. You can override this at the method level to protect expensive MCP tool execution endpoints without tightening limits on cheap health check endpoints. Method-level throttling uses update-stage patch operations with ~1-encoded path separators:

# Tighter limit on expensive MCP tool execution endpoint
aws apigateway update-stage \
  --rest-api-id abc123def \
  --stage-name prod \
  --patch-operations \
    op=replace,path=/POST~1mcp/throttling/rateLimit,value=50 \
    op=replace,path=/POST~1mcp/throttling/burstLimit,value=100

# More generous limit on cheap health/status endpoint
aws apigateway update-stage \
  --rest-api-id abc123def \
  --stage-name prod \
  --patch-operations \
    op=replace,path=/GET~1status/throttling/rateLimit,value=1000 \
    op=replace,path=/GET~1status/throttling/burstLimit,value=2000

Method throttles are additive on top of usage plan throttles — the most restrictive limit wins. The path encoding uses ~1 for forward slashes: POST /mcp/tools/call becomes /POST~1mcp~1tools~1call/throttling/rateLimit.

Per-client billing analytics with CloudWatch Count by KeyId

API Gateway emits a Count metric in the AWS/ApiGateway namespace with a KeyId dimension for every authenticated request. This is zero-code per-client usage tracking — no custom telemetry, no middleware, no DynamoDB counter tables. The KeyId dimension only appears when metricsEnabled: true is set on the stage.

For quota tracking accessible to clients themselves (to power a "you've used X of Y requests today" dashboard), use the GetUsage API:

# Get remaining quota for a specific client key
aws apigateway get-usage \
  --usage-plan-id $USAGE_PLAN_ID \
  --key-id $KEY_ID \
  --start-date $(date -u +%Y-%m-%d) \
  --end-date $(date -u +%Y-%m-%d)

# Response: {"items": {"abc123keyid": [[850, 150]]}}
# Each pair is [used, remaining] for that day
# remaining: -1 means no quota limit (enterprise tier)

The GetUsage API is itself rate-limited — avoid polling it more than once per minute per key. Cache the result and invalidate on 429 Limit Exceeded responses from the API.

Pattern 2: Canary Deployments — safe version rollouts with AliveMCP monitoring

Standard API Gateway deployments replace the stage's deploymentId atomically — 100% of traffic switches to the new version instantly with no ability to roll back without creating another deployment. For MCP servers where a new tool schema format, a changed response structure, or an updated error-handling contract could break integrations, this all-or-nothing promotion is a high-risk operation. Canary deployments extend the stage model to hold two deployments simultaneously.

The dual-deployment stage model

A stage with an active canary has four relevant fields:

API Gateway makes the baseline vs canary routing decision before any request reaches Lambda — clients have no input. The routing is random and weighted. Canary requests receive the response header X-Amzn-ApiGateway-Experimental: true; baseline requests do not. Canary deployments are a REST API feature — HTTP API v2 has no canary deployment concept.

Stage variable Lambda alias routing

Hard-coding a Lambda ARN in the integration URI requires a new API Gateway deployment every time you want to point the canary at a different Lambda version. Stage variables decouple the version selection from the API configuration: set the integration URI to arn:aws:lambda:REGION:ACCOUNT:function:mcp-handler:${stageVariables.lambdaAlias} and control which Lambda alias receives traffic through stage variable values instead of API deployments.

# Create Lambda aliases for stable and canary code paths
aws lambda create-alias \
  --function-name mcp-handler \
  --name stable \
  --function-version 1

aws lambda create-alias \
  --function-name mcp-handler \
  --name canary \
  --function-version 2

# Grant API Gateway permission to invoke both aliases
# Lambda resource policies are version/alias-specific — add permission per alias
aws lambda add-permission \
  --function-name mcp-handler:stable \
  --statement-id apigateway-stable \
  --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:us-east-1:123456789012:abc123def/prod/*/*"

aws lambda add-permission \
  --function-name mcp-handler:canary \
  --statement-id apigateway-canary \
  --action lambda:InvokeFunction \
  --principal apigateway.amazonaws.com \
  --source-arn "arn:aws:execute-api:us-east-1:123456789012:abc123def/prod/*/*"

The 5-phase canary workflow

The workflow has five phases with a critical ordering constraint at phase 4:

# PHASE 1: PREPARE — publish new Lambda version, update canary alias
NEW_VERSION=$(aws lambda publish-version \
  --function-name mcp-handler \
  --description "v2 - new tool schema format" \
  --query Version --output text)

aws lambda update-alias \
  --function-name mcp-handler \
  --name canary \
  --function-version $NEW_VERSION

# PHASE 2: ACTIVATE — create deployment with canary settings at 5%
NEW_DEPLOYMENT_ID=$(aws apigateway create-deployment \
  --rest-api-id abc123def \
  --stage-name prod \
  --canary-settings \
    percentTraffic=5,\
    stageVariableOverrides=\{lambdaAlias=canary\},\
    useStageCache=false \
  --query id --output text)

# PHASE 3: MONITOR — watch Lambda error rates by alias for 10-30 minutes
# If canary error rate rises: roll back immediately
# aws apigateway delete-stage-canary --rest-api-id abc123def --stage-name prod

# PHASE 4: PROMOTE — ramp to 100% FIRST (hard requirement — do not skip)
aws apigateway update-stage \
  --rest-api-id abc123def \
  --stage-name prod \
  --patch-operations \
    op=replace,path=/canarySettings/percentTraffic,value=100

# Wait 60 seconds for in-flight requests to complete

# PHASE 5: FINALIZE — copy canary deploymentId to main, remove canary settings
aws apigateway update-stage \
  --rest-api-id abc123def \
  --stage-name prod \
  --patch-operations \
    op=copy,path=/deploymentId,from=/canarySettings/deploymentId

aws apigateway delete-stage-canary \
  --rest-api-id abc123def \
  --stage-name prod

aws lambda update-alias \
  --function-name mcp-handler \
  --name stable \
  --function-version $NEW_VERSION

The critical constraint: in Phase 4, percentTraffic must reach 100 before copying the deployment pointer. Promoting directly from a partial canary leaves the stage in an inconsistent state where the main deploymentId was updated but canary settings still exist. This is a hard requirement documented by AWS, not a recommendation.

AliveMCP canary vs baseline health timelines

Standard uptime monitoring treats all responses from an endpoint equally — a 200 from the baseline and a 200 from the canary look identical. AliveMCP probes can use the X-Amzn-ApiGateway-Experimental: true response header to split monitoring into two separate health timelines within a single endpoint monitor:

If canary P99 climbs while baseline P99 holds steady, you have a clear visual signal to call delete-stage-canary before the canary percentage climbs further. This is more reliable than CloudWatch Lambda duration metrics alone because AliveMCP probes the full round-trip including API Gateway overhead, not just Lambda execution time.

Pattern 3: Request Validation — edge-based schema enforcement before Lambda is invoked

A public MCP endpoint without request validation receives a constant stream of structurally invalid requests from internet scanners, bots, and misconfigured clients. These requests hit Lambda, fail immediately, and produce invocation charges, CloudWatch error increments, X-Ray trace spans, and application log noise — all before any business logic runs. A single RequestValidator with a JSON Schema model for the MCP protocol envelope silently blocks all of this at the API Gateway layer for free.

The cost math

Lambda pricing is $0.0000002 per invocation. At 10 million requests per day, that is $2.00/day in invocation cost alone before duration. If 20% are structurally malformed (conservative for a public endpoint), request validation saves $0.40/day in pure Lambda waste — $146/year. The more significant saving is operational: Lambda invocations that return errors appear in your error metrics and can make real error signals hard to detect. API Gateway validation removes these pre-invocation, keeping error rates meaningful and alarm thresholds tight.

JSON Schema model for the MCP protocol envelope

API Gateway supports JSON Schema draft-04 only. Draft-07 keywords like if/then/else, const, and contains are silently ignored — they do not cause an error, but the validation logic they define does not run. Every valid MCP request has a method string and an id (string or integer) — any request missing these fields is structurally invalid and can be safely rejected at the edge:

{
  "$schema": "http://json-schema.org/draft-04/schema#",
  "title": "MCPToolCallRequest",
  "type": "object",
  "required": ["method", "id"],
  "properties": {
    "method": {
      "type": "string",
      "pattern": "^(tools/call|tools/list|resources/read|prompts/get)$"
    },
    "id": {
      "type": ["string", "integer"]
    },
    "params": {
      "type": "object"
    }
  },
  "additionalProperties": false
}

Four-step validation setup

Both the RequestValidator resource and the Model resource must be attached to the method — either one alone does nothing. The most common mistake is creating the validator and the model but forgetting to attach both to the method, or attaching them and forgetting to redeploy.

# Step 1: Create the model
aws apigateway create-model \
  --rest-api-id abc123def \
  --name MCPToolCallRequest \
  --content-type "application/json" \
  --schema file:///tmp/mcp-request-model.json

# Step 2: Create the RequestValidator
VALIDATOR_ID=$(aws apigateway create-request-validator \
  --rest-api-id abc123def \
  --name "mcp-body-and-params-validator" \
  --validate-request-body \
  --validate-request-parameters \
  --query id --output text)

# Step 3: Attach BOTH validator AND model to the method
# omitting either one means validation does not run
aws apigateway update-method \
  --rest-api-id abc123def \
  --resource-id $RESOURCE_ID \
  --http-method POST \
  --patch-operations \
    op=replace,path=/requestValidatorId,value=$VALIDATOR_ID \
    op=replace,path=/requestModels/application~1json,value=MCPToolCallRequest

# Step 4: Redeploy — method changes require a deployment to take effect
aws apigateway create-deployment \
  --rest-api-id abc123def \
  --stage-name prod \
  --description "Enable request validation"

Customizing 400 errors with GatewayResponse

The default validation failure response body is {"message": "Invalid request body"} — it tells the client something is wrong but not what. For MCP server clients that are themselves developer tools, this is frustrating. Use GatewayResponse to include $context.error.validationErrorString, which contains the specific JSON Schema failure message:

aws apigateway put-gateway-response \
  --rest-api-id abc123def \
  --response-type REQUEST_VALIDATION_ERROR \
  --status-code 400 \
  --response-templates '{"application/json": "{\"message\": \"$context.error.message\", \"detail\": \"$context.error.validationErrorString\", \"schema\": \"MCPToolCallRequest\"}"}' \
  --response-parameters '{"gatewayresponse.header.Access-Control-Allow-Origin": "'"'"'*'"'"'"}'

With this customization, a missing method field returns:

{
  "message": "Invalid request body",
  "detail": "object has missing required properties ([\"method\"])",
  "schema": "MCPToolCallRequest"
}

The CORS header in the GatewayResponse is not optional for browser-based MCP clients. Without it, a 400 validation rejection from API Gateway does not include Access-Control-Allow-Origin, and browsers display a misleading "CORS error" instead of the actual 400 message — the client sees no information about what was wrong with the request.

Parameter presence validation

RequestValidator can also check that required headers and query strings are present on every request. This is presence-only — it checks that the parameter exists, not what value it contains. To require a session-id header:

# Enable parameter validation on the validator
aws apigateway update-request-validator \
  --rest-api-id abc123def \
  --request-validator-id $VALIDATOR_ID \
  --patch-operations \
    op=replace,path=/validateRequestParameters,value=true

# Declare required parameters on the method
# value=true = required (missing returns 400), value=false = documented but optional
aws apigateway update-method \
  --rest-api-id abc123def \
  --resource-id $RESOURCE_ID \
  --http-method POST \
  --patch-operations \
    "op=add,path=/requestParameters/method.request.header.x-session-id,value=true"

Use lowercase consistently for header parameter declarations (method.request.header.x-session-id not method.request.header.X-Session-Id). API Gateway normalizes incoming headers to lowercase before matching, but if your declaration uses mixed case, matching behavior can be inconsistent across deployment environments.

Pattern 4: Custom Domains — stable URLs, version routing, and enterprise mTLS

The default execute-api.amazonaws.com hostname changes whenever the API is recreated. This means client integrations hard-coded to the default URL break silently on API replacement — a common operation during major version upgrades or incident recovery. Custom domains replace the volatile hostname with a stable, branded URL that you control, and base path mappings enable routing different path prefixes to different API stages without any client reconfiguration.

Regional vs edge-optimized: always choose regional

API Gateway offers two endpoint types for custom domains. The comparison determines your certificate region requirements and downstream flexibility:

AttributeRegionalEdge-Optimized
ACM certificate regionSame as API deployment regionMust be us-east-1 (CloudFront requirement)
TLS terminationAPI Gateway in your regionNearest CloudFront edge PoP
Route53 record targetregionalDomainName (A alias)distributionDomainName (CNAME or A alias)
mTLS supportYesNo
Certificate auto-renewal propagation<2 minutes~5 minutes, can require manual trigger
CloudFront controlFull — add your own CloudFront if neededManaged by API Gateway, no custom behaviors

AWS's own documentation now recommends regional endpoints for new deployments. Edge-optimized is a legacy default that predates regional support. For MCP servers, regional is the right choice in every scenario: it supports mTLS, keeps certificate management in one region, and lets you add CloudFront manually with custom cache behaviors if you need global edge presence later.

Four-phase creation workflow

# PHASE 1: Request and validate ACM certificate with DNS validation
CERT_ARN=$(aws acm request-certificate \
  --domain-name api.yourmcp.com \
  --validation-method DNS \
  --query CertificateArn --output text)

# Add the DNS validation CNAME to Route53, then wait
aws acm wait certificate-validated --certificate-arn $CERT_ARN

# PHASE 2: Create the regional custom domain name
DOMAIN_OUTPUT=$(aws apigateway create-domain-name \
  --domain-name api.yourmcp.com \
  --endpoint-configuration types=REGIONAL \
  --regional-certificate-arn $CERT_ARN \
  --security-policy TLS_1_2)

# IMPORTANT: save the regionalDomainName — this is what Route53 points at
# NOT the execute-api URL — they are completely different endpoints
REGIONAL_DOMAIN=$(echo $DOMAIN_OUTPUT | jq -r '.regionalDomainName')
REGIONAL_HZ=$(echo $DOMAIN_OUTPUT | jq -r '.regionalHostedZoneId')
# regionalDomainName looks like: d-abc123def.execute-api.us-east-1.amazonaws.com
# Note the d- prefix — it is NOT the same as abc123def.execute-api...

# PHASE 3: Create base path mappings
# base path must NOT have a leading slash — "v1" not "/v1"
aws apigateway create-base-path-mapping \
  --domain-name api.yourmcp.com \
  --rest-api-id abc123def \
  --stage prod \
  --base-path v1

# PHASE 4: Create Route53 A alias record
# AliasTarget.DNSName = regionalDomainName (not execute-api URL)
# AliasTarget.HostedZoneId = regionalHostedZoneId from create-domain-name output
aws route53 change-resource-record-sets \
  --hosted-zone-id $HOSTED_ZONE_ID \
  --change-batch "{
    \"Changes\": [{
      \"Action\": \"CREATE\",
      \"ResourceRecordSet\": {
        \"Name\": \"api.yourmcp.com\",
        \"Type\": \"A\",
        \"AliasTarget\": {
          \"HostedZoneId\": \"$REGIONAL_HZ\",
          \"DNSName\": \"$REGIONAL_DOMAIN\",
          \"EvaluateTargetHealth\": false
        }
      }
    }]
  }"

Base path mapping versioning and path stripping

Base path mappings enable API versioning without any client reconfiguration: api.yourmcp.com/v1 routes to the v1 API stage and api.yourmcp.com/v2 routes to the v2 API stage. When a request arrives at api.yourmcp.com/v1/mcp/tools/call, API Gateway strips the base path (v1) before forwarding — your Lambda handler receives /mcp/tools/call, not /v1/mcp/tools/call. Design your API routes assuming the base path prefix will never reach Lambda.

To sunset an old API version without client disruption, update the base path mapping to point to the new stage, or return a 410 Gone response from a Lambda that logs all remaining v1 callers:

# Update v1 mapping to point to a new stage after a major version upgrade
aws apigateway update-base-path-mapping \
  --domain-name api.yourmcp.com \
  --base-path v1 \
  --patch-operations \
    op=replace,path=/stage,value=prod-v2

# List all mappings for a domain
aws apigateway get-base-path-mappings \
  --domain-name api.yourmcp.com

Mutual TLS for enterprise clients

Enterprise MCP clients in regulated industries often require mutual TLS: the client must present a certificate signed by a trusted CA that you specify, in addition to the server's TLS certificate. API Gateway supports mTLS on regional custom domains via a PEM-encoded truststore bundle stored in S3.

The critical operational detail: API Gateway does not poll the S3 object for updates. After uploading a new CA to the truststore bundle, you must explicitly call update-domain-name with the new S3 object version ID to trigger re-ingestion. Skipping this step means newly added CAs are not trusted and newly issued client certificates will be rejected.

# S3 bucket must have versioning enabled — required by mTLS truststore
aws s3api put-bucket-versioning \
  --bucket yourmcp-mtls-truststore \
  --versioning-configuration Status=Enabled

aws s3 cp truststore.pem s3://yourmcp-mtls-truststore/truststore.pem

TRUSTSTORE_VERSION=$(aws s3api head-object \
  --bucket yourmcp-mtls-truststore \
  --key truststore.pem \
  --query VersionId --output text)

# Create domain with mTLS enabled
aws apigateway create-domain-name \
  --domain-name enterprise.yourmcp.com \
  --endpoint-configuration types=REGIONAL \
  --regional-certificate-arn $CERT_ARN \
  --security-policy TLS_1_2 \
  --mutual-tls-authentication \
    truststoreUri=s3://yourmcp-mtls-truststore/truststore.pem,\
    truststoreVersion=$TRUSTSTORE_VERSION

# After updating the truststore (adding a new CA cert):
# 1. Upload the new bundle to S3
# 2. Get the new version ID
# 3. Trigger API Gateway re-ingestion — API Gateway never polls S3 automatically
NEW_VERSION=$(aws s3api head-object \
  --bucket yourmcp-mtls-truststore \
  --key truststore.pem --query VersionId --output text)

aws apigateway update-domain-name \
  --domain-name enterprise.yourmcp.com \
  --patch-operations \
    op=replace,path=/mutualTlsAuthentication/truststoreVersion,value=$NEW_VERSION

mTLS and usage plan API keys can be layered on the same domain: the client must pass both the mTLS certificate check (enforced at the domain level, network identity) and provide a valid API key (enforced at the stage level, billing tier). When a client certificate fails mTLS, API Gateway returns a 403 without CORS headers — add a GatewayResponse for UNAUTHORIZED type to include Access-Control-Allow-Origin so browser-based MCP clients see the real error instead of a misleading CORS failure.

Production checklist

CheckPatternVerify with
Stage metricsEnabled: true is set Usage Plans aws apigateway get-stage --query defaultRouteSettings.detailedMetricsEnabled or CloudWatch Count metric has KeyId dimension
Each API key has a UsagePlanKey resource linking it to a plan Usage Plans aws apigateway get-usage-plan-keys --usage-plan-id $PLAN_ID lists the key
Lambda aliases have distinct IAM resource-based policies for apigateway.amazonaws.com Canary Deployments aws lambda get-policy --function-name mcp-handler:stable and :canary both return non-empty policies
Canary promotion sequence: percentTraffic=100 → copy deploymentId → delete-stage-canary Canary Deployments aws apigateway get-stage --query canarySettings returns null after promotion
Method has both requestValidatorId AND requestModels set Request Validation aws apigateway get-method --query "{v:requestValidatorId,m:requestModels}" — both non-null
GatewayResponse for REQUEST_VALIDATION_ERROR includes CORS header Request Validation curl -X POST $ENDPOINT -H "Content-Type: application/json" -d '{"bad":"data"}' — response includes Access-Control-Allow-Origin
Route53 A record points to regionalDomainName (d- prefix), not execute-api URL Custom Domains aws apigateway get-domain-name --query regionalDomainName matches the Route53 alias target
Base path strings have no leading slash Custom Domains aws apigateway get-base-path-mappings --domain-name api.yourmcp.com — basePath shows v1 not /v1
mTLS truststore update triggers update-domain-name with new S3 version ID Custom Domains aws apigateway get-domain-name --query mutualTlsAuthentication.truststoreVersion matches current S3 object version
API redeployed after model/validator/method changes Request Validation aws apigateway get-stage --query deploymentId matches the latest create-deployment output

Failure modes across all four patterns

SymptomPatternCause and fix
403 Forbidden on authenticated requests despite key existing and being enabled Usage Plans Missing UsagePlanKey resource — the key and plan exist but are not linked. Run create-usage-plan-key --usage-plan-id $PLAN_ID --key-id $KEY_ID --key-type API_KEY. Also verify the plan has an apiStages association for your API ID and stage name
Canary traffic percentage not splitting — all requests going to one path Canary Deployments Minimum effective canary percentage is 0.01%. Values below 0.005% round to 0 (no canary traffic). At low traffic volumes, use 5–10% minimum to get statistically meaningful canary observations within a reasonable monitoring window
Invalid request bodies reaching Lambda despite model and validator being configured Request Validation The method has either requestValidatorId or requestModels but not both — either alone does nothing. Run get-method --query "{v:requestValidatorId,m:requestModels}". Also check whether the API was redeployed after adding the validator and model to the method
Custom domain DNS resolves to wrong IP or NXDOMAIN Custom Domains Route53 A record points to the execute-api URL instead of the regionalDomainName. Run aws apigateway get-domain-name --domain-name api.yourmcp.com --query regionalDomainName — the value starts with d-. Update the Route53 alias target to this value with the corresponding regionalHostedZoneId
mTLS rejecting valid client certificates after truststore update Custom Domains API Gateway is still using the old ingested bundle. Upload the new PEM to S3, get the new S3 object version ID, and call update-domain-name --patch-operations op=replace,path=/mutualTlsAuthentication/truststoreVersion,value=$NEW_VERSION. API Gateway never polls S3 — every truststore change requires this explicit re-ingestion trigger
Lambda canary alias permission errors (403) while stable alias works Canary Deployments Lambda resource-based policies are alias-specific when the source-arn includes a qualifier. The policy on the unqualified function or a different alias does not cover the canary alias ARN. Add an explicit permission for mcp-handler:canary with the full execute-api source ARN
Browsers show CORS error instead of 400 validation message Request Validation Default GatewayResponse for REQUEST_VALIDATION_ERROR does not include Access-Control-Allow-Origin. Add the header via put-gateway-response --response-parameters '{"gatewayresponse.header.Access-Control-Allow-Origin": "'"'"'*'"'"'"}'
Base path mapping returns 403 immediately after creation Custom Domains Base path mappings take 1–3 minutes to propagate to all regional edge nodes after creation. If still failing after 5 minutes, check the base path string has no leading slash (v1 not /v1) — a leading slash causes the mapping to be created but never match incoming requests