Guide · AWS API Gateway · Input Validation
AWS API Gateway Request Validation for MCP Servers — JSON Schema Models, Parameter Validators, and 400 Errors
API Gateway's built-in RequestValidator evaluates incoming request bodies against a JSON Schema model and checks required headers and query strings before invoking Lambda — meaning malformed MCP tool calls are rejected at the edge with a 400 response and Lambda is never invoked, never billed. For public-facing MCP endpoints, this is not just a cost optimization: internet scanners and automated bots send a constant stream of garbage requests that would otherwise hit your Lambda, inflate your error metrics, and pollute your CloudWatch logs with irrelevant noise. A single RequestValidator with a tight JSON Schema model for the MCP protocol envelope silently blocks all of that before it reaches your code. Request validation is a REST API feature — HTTP API (v2) has no RequestValidator concept and you must push validation into Lambda. For per-client rate limiting that works alongside validation, see API Gateway usage plans; for verifying the caller's identity before checking the body, see Lambda authorizers.
TL;DR
Create a JSON Schema draft-04 Model defining your MCP request envelope (required method and id fields, optional params object). Create a RequestValidator with validateRequestBody: true. Attach both to the POST method via requestModels and requestValidatorId — both must be set on the method, not just the validator resource. API Gateway returns 400 Bad Request with body {"message": "Invalid request body"} without invoking Lambda. For required headers and query strings, add validateRequestParameters: true to the validator and set requestParameters: {"method.request.querystring.session": true} on the method — this checks presence, not value format.
What request validation saves
Lambda pricing is $0.0000002 per invocation (200-microsecond price tier for the first 6 billion requests/month) plus duration. At 10 million requests per day, that is $2.00/day in invocation cost alone — before duration. If 20% of those requests are malformed (a conservative estimate for a public MCP endpoint), you are paying $0.40/day to invoke Lambda just to return a 400 error. Over a year, that is $146 in pure waste.
The more significant cost is operational. Lambda invocations that return errors appear in your CloudWatch Lambda error metrics, your X-Ray traces, and your application logs. A public endpoint without input validation routinely sees 15–30% error rates from scanners, bots, and misconfigured clients — error rates that make your real error signal hard to detect, trigger false alarms, and train your team to ignore error alerts. API Gateway validation eliminates these before they become Lambda invocations, keeping your error metrics clean and your alarm thresholds meaningful.
For MCP servers specifically, the protocol envelope is well-defined: every valid MCP request has a method field (a string matching one of a small set of known values) and an id field (string or integer). Any request missing these fields is structurally invalid and can be rejected without business logic — exactly what API Gateway validation does for free.
Creating a model and validator
The workflow has three steps: create the JSON Schema model, create the RequestValidator resource, and attach both to the method. Each step is independent — the model and validator are both reusable resources that can be attached to multiple methods.
Step 1: Define the JSON Schema model
API Gateway supports JSON Schema draft-04 only. Draft-07 keywords like if/then/else, contains, and propertyNames are silently ignored — not rejected. The following model covers the MCP protocol envelope:
{
"$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
}
The additionalProperties: false setting rejects any request that includes fields not listed in properties. This is optional but useful for strict protocol enforcement — it prevents clients from passing unexpected fields that might confuse your Lambda handler.
Step 2: Create the model resource
# Create the model (save schema JSON to a file first)
cat > /tmp/mcp-request-model.json << 'EOF'
{
"$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"
}
}
}
EOF
aws apigateway create-model \
--rest-api-id abc123def \
--name MCPToolCallRequest \
--content-type "application/json" \
--schema file:///tmp/mcp-request-model.json
Step 3: Create the RequestValidator
# Create a validator that checks both body and parameters
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)
echo "Validator ID: $VALIDATOR_ID"
# Create a body-only validator (cheaper — doesn't require parameter declarations on each method)
BODY_VALIDATOR_ID=$(aws apigateway create-request-validator \
--rest-api-id abc123def \
--name "mcp-body-validator" \
--validate-request-body \
--no-validate-request-parameters \
--query id --output text)
Step 4: Attach to the method
# Assuming the method already exists — attach validator and model
# Both requestValidatorId AND requestModels must be set; either one alone is insufficient
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
# Verify the method configuration
aws apigateway get-method \
--rest-api-id abc123def \
--resource-id $RESOURCE_ID \
--http-method POST \
--query '{validator: requestValidatorId, model: requestModels}'
After attaching, redeploy the API to the stage — model and validator changes require a deployment to take effect. Without redeploying, the validator is configured but not active on the stage.
Parameter validation for required headers and query strings
RequestValidator can enforce that specific headers and query string parameters are present on every request. This is a presence check — not a value check. If you need to validate the format or value of a parameter, use the JSON Schema model on the body (for body fields) or a Lambda authorizer (for header/query value validation).
Parameter validation configuration is split across two places: the validator (which enables validation) and the method (which declares which parameters are required). Both must be configured.
# Require both x-api-key header and session-id query string
# Set the validator to check parameters
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
# method.request.header.NAME or method.request.querystring.NAME
# value=true means required; value=false means allowed but not required
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-api-key,value=true" \
"op=add,path=/requestParameters/method.request.querystring.session-id,value=false"
In the above example, the x-api-key header is required (missing it returns 400), while session-id query string is declared but optional (missing it is allowed). Setting a parameter to value=false in requestParameters with validateRequestParameters: true is still useful — it explicitly documents the parameter and enables downstream mapping templates to reference it safely.
A subtle but important point about header names: API Gateway parameter validation is case-sensitive for the parameter name you declare, but HTTP headers are case-insensitive by protocol. If you declare method.request.header.x-api-key (lowercase), API Gateway normalizes incoming headers to lowercase before validation, so both X-Api-Key and x-api-key from the client will match. However, if you mix case in your declaration (e.g., method.request.header.X-Api-Key), the matching behavior may differ — use lowercase consistently in parameter declarations to avoid surprises.
CDK setup for REST API validation
Request validation in CDK uses the RequestValidator construct and JsonSchema helper from aws-cdk-lib/aws-apigateway. This is a REST API (RestApi) feature only — HttpApi does not have RequestValidator support.
import * as apigateway from 'aws-cdk-lib/aws-apigateway';
import * as cdk from 'aws-cdk-lib';
const api = new apigateway.RestApi(this, 'McpServerApi', {
restApiName: 'mcp-server-api',
});
// Create the JSON Schema model for MCP tool call requests
const mcpRequestModel = api.addModel('MCPToolCallRequest', {
contentType: 'application/json', // required — must be set explicitly in CDK
modelName: 'MCPToolCallRequest',
schema: {
schema: apigateway.JsonSchemaVersion.DRAFT4,
title: 'MCPToolCallRequest',
type: apigateway.JsonSchemaType.OBJECT,
required: ['method', 'id'],
properties: {
method: {
type: apigateway.JsonSchemaType.STRING,
pattern: '^(tools/call|tools/list|resources/read|prompts/get)$',
},
id: {
type: [apigateway.JsonSchemaType.STRING, apigateway.JsonSchemaType.INTEGER],
},
params: {
type: apigateway.JsonSchemaType.OBJECT,
},
},
},
});
// Create the RequestValidator
const requestValidator = new apigateway.RequestValidator(this, 'McpRequestValidator', {
restApi: api,
requestValidatorName: 'mcp-body-and-params-validator',
validateRequestBody: true,
validateRequestParameters: true,
});
// Get the resource and add the method with validator and model
const mcpResource = api.root.addResource('mcp');
// Lambda integration
const mcpHandler = lambda.Function.fromFunctionArn(this, 'McpHandler',
'arn:aws:lambda:us-east-1:123456789012:function:mcp-handler');
const mcpIntegration = new apigateway.LambdaIntegration(mcpHandler);
mcpResource.addMethod('POST', mcpIntegration, {
requestValidator,
requestModels: {
'application/json': mcpRequestModel,
},
requestParameters: {
'method.request.header.x-api-key': true, // required
'method.request.querystring.session-id': false, // optional but declared
},
});
// GET /status endpoint — no validation needed (no body, no required params)
const statusResource = api.root.addResource('status');
statusResource.addMethod('GET', new apigateway.LambdaIntegration(mcpHandler));
The contentType: 'application/json' field on the model is required in CDK even though it defaults to application/json — without it, CDK generates a model without a content type and the CloudFormation deployment will fail with a validation error. Always set it explicitly.
Customizing 400 error responses
By default, a validation failure returns:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"message": "Invalid request body"}
This tells the client that something is wrong with their request body, but not what. For MCP server clients that are developer tools themselves, this is frustrating — they need to know which field failed, what the expected format is, and ideally which schema rule was violated. API Gateway GatewayResponses let you customize the error body for specific error types including REQUEST_VALIDATION_ERROR.
# Customize the 400 validation error response to include the schema name and error message
# $context.error.validationErrorString contains the specific validation 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 request missing the method field returns:
{
"message": "Invalid request body",
"detail": "object has missing required properties ([\"method\"])",
"schema": "MCPToolCallRequest"
}
The $context.error.validationErrorString variable is populated by API Gateway with the JSON Schema validation failure message from the underlying validator library. The format is fixed by the library — you cannot change the message text, only include or exclude it in your response template. The message is descriptive enough for most debugging purposes: it includes the missing property name, the type mismatch, or the pattern violation with the failing value.
You can also use GatewayResponse to add CORS headers to validation error responses — by default, a 400 from API Gateway does not include Access-Control-Allow-Origin headers, which means browsers show a misleading CORS error instead of the actual 400 validation message. Adding the CORS header to the GatewayResponse ensures browser-based MCP clients see the real error message.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Model validation not triggering — invalid request bodies reaching Lambda without a 400 response | The method has either the requestValidatorId or the requestModels set, but not both. Both must be present on the method configuration. Creating a validator resource alone does nothing — it must be referenced by the method. Similarly, attaching a model without a validator does nothing |
Verify both are set: aws apigateway get-method --rest-api-id $API_ID --resource-id $RES_ID --http-method POST --query "{validator: requestValidatorId, models: requestModels}". Both fields must be non-null. After fixing, redeploy the API to the stage — method changes require a deployment |
| 400 errors for requests with a valid JSON body that passes the schema manually | API Gateway only supports JSON Schema draft-04. If the model was written using draft-07 features (if/then/else, const, contains), those keywords are silently ignored during validation — but required and properties from the same schema may interact differently than expected when the draft-07 conditional logic is stripped out, causing unexpected required field failures |
Rewrite the model using only draft-04 keywords: type, properties, required, pattern, enum, minimum, maximum, minLength, maxLength, items, anyOf, allOf, oneOf. Test the schema against the JSON Schema Validator API in the console before deploying |
| Parameter validation rejecting requests that include the correct headers and query strings | Parameter names in the declaration are case-sensitive in the validator configuration. If you declared method.request.header.X-Session-Id (mixed case) but the client sends x-session-id (lowercase), and API Gateway normalizes headers to lowercase before matching, there can be a mismatch depending on how the API was deployed |
Use lowercase consistently for all header parameter declarations: method.request.header.x-session-id not method.request.header.X-Session-Id. Query string parameters are matched exactly as declared — use the same case as the client sends |
| Scanners and bots still reaching Lambda despite a model and validator being attached | Only methods with both requestModels and requestValidatorId configured are validated. If your API has multiple resources or methods, only the explicitly configured ones are protected — other methods bypass validation entirely even on the same stage. A common mistake is attaching the model to one method and assuming all methods on that resource are covered |
Audit all methods: aws apigateway get-resources --rest-api-id $API_ID --query "items[].resourceMethods" and verify each POST/PUT/PATCH method that accepts a body has both requestValidatorId and requestModels set. Create a shared validator resource and attach it to each method individually |
| CDK deployment fails with "Model content-type not set" or CloudFormation validation error on the model resource | The CDK addModel call omitted contentType: 'application/json'. Although this field has a default value in CDK's TypeScript interface, the underlying CloudFormation resource requires an explicit content type value |
Add contentType: 'application/json' explicitly to every addModel or new apigateway.Model call. This is a known CDK footgun — the TypeScript type allows omission but CloudFormation rejects the resulting template |