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
- Usage plans — associate API keys with per-key throttle and quota; use CloudWatch Count by KeyId for per-client billing analytics; handle two distinct 429 messages differently: Create a usage plan with
--throttle rateLimit=500,burstLimit=1000and--quota limit=100000,period=DAY, create an API key, link them withcreate-usage-plan-key, and associate the plan with the stage viaapiStages. Clients send the key inx-api-keyheader (not query param — query params appear in access logs).429 Limit Exceeded= quota cap reached, wait for period reset.429 Too Many Requests= burst throttle hit, use exponential backoff. EnablemetricsEnabled: trueon the stage or theKeyIddimension never appears in CloudWatch. REST API only — HTTP API v2 has no usage plans. - Canary deployments — use stage variable Lambda alias routing to split traffic without repeated API deployments; always set percentTraffic=100 before promoting; use X-Amzn-ApiGateway-Experimental header for canary vs baseline monitoring: Wire the integration URI to
${stageVariables.lambdaAlias}, set baseline stage variablelambdaAlias=stable, set canarystageVariableOverrides={"lambdaAlias":"canary"}. Create a new deployment with--canary-settings percentTraffic=5,stageVariableOverrides=\{lambdaAlias=canary\}. Monitor error rates on both Lambda aliases for 10–30 minutes. If clean: ramp to 100%, copy canary deploymentId to main deploymentId, then calldelete-stage-canary. Instant rollback at any point:aws apigateway delete-stage-canary. Use theX-Amzn-ApiGateway-Experimental: trueheader to create separate AliveMCP health timelines for canary vs baseline P99. - Request validation — both requestModels AND requestValidatorId must be set on the method; JSON Schema draft-04 only; redeploy after attaching; use GatewayResponse to return actionable 400 messages: Create a JSON Schema draft-04 model (no draft-07 keywords — they are silently ignored), create a RequestValidator with
--validate-request-body, then callupdate-methodwith bothop=replace,path=/requestValidatorId,value=$VALIDATOR_IDANDop=replace,path=/requestModels/application~1json,value=MCPToolCallRequest. Redeploy. Customize 400 response viaput-gateway-response --response-type REQUEST_VALIDATION_ERRORusing$context.error.validationErrorStringfor the specific failing field. AddAccess-Control-Allow-Originto GatewayResponse or browsers show a misleading CORS error instead of the real 400. REST API only. - Custom domains — use regional endpoints, not edge-optimized; point Route53 at regionalDomainName (d-prefix), not the execute-api URL; base path prefix is stripped before Lambda sees the path; mTLS requires update-domain-name to re-ingest after truststore updates: Request ACM certificate in the API's region (not us-east-1 unless edge-optimized), call
create-domain-name --endpoint-configuration types=REGIONAL --regional-certificate-arn $CERT_ARN, save theregionalDomainNamefrom the output. Create base path mappings (v1not/v1— no leading slash). Create Route53 A alias record pointing toregionalDomainNamewithregionalHostedZoneId. For mTLS: upload PEM bundle to S3 with versioning enabled, specifytruststoreUriandtruststoreVersionat domain creation, and after every truststore update callupdate-domain-namewith the new S3 version ID — API Gateway never polls S3 automatically.
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:
- Throttle (
rateLimit+burstLimit): A token-bucket real-time rate limiter.rateLimitis the sustained rate in requests per second;burstLimitis how many tokens can accumulate when the rate is below the limit, which can be consumed in a single burst. Exceeding burst returns429 {"message": "Too Many Requests"}immediately. The correct client response is exponential backoff with jitter. - Quota (
limit+period): A hard cap on total request count over a fixed calendar period —DAY,WEEK, orMONTH. Once the cap is reached, all subsequent requests return403 {"message": "Limit Exceeded"}until the period resets at midnight UTC. The correct client response is to stop calling until the reset time, not to retry.
A typical MCP server product tier structure might look like this:
| Tier | Rate (req/sec) | Burst | Daily Quota | Monthly Price |
|---|---|---|---|---|
| Free | 10 | 20 | 1,000 | $0 |
| Starter | 100 | 200 | 10,000 | $29 |
| Pro | 500 | 1,000 | 100,000 | $99 |
| Enterprise | 1,000 | 5,000 | No quota | Custom |
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:
deploymentId— the baseline snapshot (currently serving most traffic)canarySettings.deploymentId— the new canary snapshot (serving canary percentage)canarySettings.percentTraffic— fraction from 0.01 to 100 routed to canarycanarySettings.stageVariableOverrides— stage variable values applied only to canary requests
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:
- Baseline health timeline: Probes that did not receive the experimental header — the stable code path's availability and round-trip latency over the rollout window.
- Canary health timeline: Probes that received the experimental header — the new code path's availability and latency being evaluated before full promotion.
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:
| Attribute | Regional | Edge-Optimized |
|---|---|---|
| ACM certificate region | Same as API deployment region | Must be us-east-1 (CloudFront requirement) |
| TLS termination | API Gateway in your region | Nearest CloudFront edge PoP |
| Route53 record target | regionalDomainName (A alias) | distributionDomainName (CNAME or A alias) |
| mTLS support | Yes | No |
| Certificate auto-renewal propagation | <2 minutes | ~5 minutes, can require manual trigger |
| CloudFront control | Full — add your own CloudFront if needed | Managed 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
| Check | Pattern | Verify 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
| Symptom | Pattern | Cause 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 |