AWS Lambda · 2026-09-02 · AWS Lambda arc
AWS Lambda for MCP Servers: Function URLs, Cold Start Elimination, Container Images, and Edge Distribution — Four Production Patterns
AWS Lambda is a genuinely viable runtime for MCP servers — but almost every team that deploys there discovers the same four failure classes in the same order: the API Gateway 29-second timeout that kills long tool calls, cold start latency that makes the first MCP session painful, a dependency tree that overflows the 250 MB zip limit, and a single-region origin that adds unnecessary round-trip latency for global users. The fixes exist for all four, and they compose cleanly. This post synthesizes the complete Lambda arc: Function URLs as the correct HTTP layer, Provisioned Concurrency and SnapStart as the cold-start toolkit, container images as the escape hatch for large dependency trees, and Lambda@Edge as the edge layer that belongs in front of regional origins rather than as the execution environment itself.
Pattern 1 — Lambda Function URLs: no 29s timeout, RESPONSE_STREAM for SSE, concurrency model, CORS placement
The most common Lambda MCP deployment starts with API Gateway HTTP API because that is the default recommendation for Lambda-backed HTTP APIs. It works — until a tool call runs longer than 29 seconds. API Gateway has a hard integration timeout of 29 seconds that cannot be extended. Lambda Function URLs do not have a separate gateway timeout: the only limit is the Lambda function timeout, which goes up to 900 seconds (15 minutes). For any MCP server where tool calls may involve database queries, LLM sub-calls, code execution, or file processing, Function URLs are the correct HTTP layer.
The second issue is SSE streaming. API Gateway buffers the entire response body before forwarding it to the client. Lambda Function URLs in RESPONSE_STREAM invoke mode flush bytes to the client as they are produced. This is the mechanism that makes SSE transport work over Function URLs — without it, an MCP client using SSE receives all events at once at the end of the tool call rather than incrementally.
The third issue — and the most architecturally important — is the concurrency model. On ECS Fargate, a single Node.js task with an event loop handles hundreds of concurrent SSE sessions through async I/O. On Lambda, each Function URL invocation handles exactly one HTTP request. One hundred concurrent MCP sessions consume one hundred concurrent Lambda invocations, each counting against your account's reserved concurrency limit (default 1,000 per region).
| Dimension | API Gateway HTTP API | Lambda Function URL |
|---|---|---|
| Max timeout | 29 seconds (hard limit, cannot be extended) | Lambda function timeout — up to 900s |
| SSE streaming | No — buffers entire response before delivery | Yes — RESPONSE_STREAM flushes incrementally |
| Custom domain | Native support | Via CloudFront + origin access control |
| Auth at gateway | JWT authorizer, IAM, Lambda authorizer | IAM (SigV4) or NONE; application-layer auth in handler |
| Request routing | Path and method routing across multiple Lambdas | Single function per URL |
| Request charge | $1.00 / million requests + data transfer | Function URL requests are free; pay Lambda invocation only |
| Concurrency model | One invocation per request | One invocation per request |
Function URL CDK configuration
The CDK addFunctionUrl method attaches a Function URL to any Lambda function. Use invokeMode: RESPONSE_STREAM for SSE transport. Set CORS on the Function URL configuration — not inside the handler. When CORS is configured on the Function URL, OPTIONS preflight responses are handled automatically by the Function URL layer before the streaming invocation begins. Returning a CORS response from inside a streaming handler is error-prone because the preflight must complete before the stream starts.
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as lambdaNodeJs from "aws-cdk-lib/aws-lambda-nodejs";
import { Duration } from "aws-cdk-lib";
const mcpFn = new lambdaNodeJs.NodejsFunction(this, "McpFunction", {
entry: "src/lambda-mcp-handler.ts",
handler: "handler",
runtime: lambda.Runtime.NODEJS_22_X,
timeout: Duration.minutes(5), // up to 15 minutes — no gateway cap
memorySize: 512,
environment: {
NODE_ENV: "production",
MCP_TRANSPORT: "http",
},
});
const fnUrl = mcpFn.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE, // public endpoint
invokeMode: lambda.InvokeMode.RESPONSE_STREAM, // required for SSE
cors: {
allowedOrigins: ["*"],
allowedHeaders: ["content-type", "authorization"],
allowedMethods: [lambda.HttpMethod.POST, lambda.HttpMethod.GET],
},
});
new cdk.CfnOutput(this, "McpFunctionUrl", { value: fnUrl.url });
Streaming handler for SSE transport
Lambda's standard invocation model buffers the entire response. RESPONSE_STREAM invoke mode requires wrapping your handler in streamifyResponse and writing to the provided ResponseStream. The HttpResponseStream.from call sets status code and headers before streaming begins — this metadata must be written before the first content byte.
// src/lambda-mcp-handler.ts
import { streamifyResponse, ResponseStream } from "aws-lambda";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
export const handler = streamifyResponse(
async (event: AWSLambda.APIGatewayProxyEventV2, responseStream: ResponseStream) => {
const authHeader = event.headers?.authorization ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : null;
if (!token || !isValidToken(token)) {
const meta = { statusCode: 401, headers: { "Content-Type": "application/json" } };
const resp = awslambda.HttpResponseStream.from(responseStream, meta);
resp.write(JSON.stringify({ error: "Unauthorized" }));
resp.end();
return;
}
const httpResponseMetadata = {
statusCode: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
};
const stream = awslambda.HttpResponseStream.from(responseStream, httpResponseMetadata);
const transport = new StreamableHTTPServerTransport({
write: (data: string) => stream.write(data),
end: () => stream.end(),
});
const server = new Server(
{ name: "my-mcp", version: "1.0.0" },
{ capabilities: { tools: {} } }
);
// ... register tools ...
await server.connect(transport);
await transport.handleRequest(event.body ?? "", event.headers);
}
);
Concurrency management
The one-invocation-per-session model means concurrent MCP sessions directly map to concurrent Lambda invocations. Set reserved concurrency on the function to prevent it from consuming your entire account concurrency quota and starving other functions. Monitor ConcurrentExecutions and Throttles in CloudWatch.
# Set reserved concurrency — prevents Lambda from starving other functions
aws lambda put-function-concurrency \
--function-name McpFunction \
--reserved-concurrent-executions 200
# Monitor live concurrency
aws cloudwatch get-metric-statistics \
--namespace AWS/Lambda \
--metric-name ConcurrentExecutions \
--dimensions Name=FunctionName,Value=McpFunction \
--start-time $(date -u -d '1 hour ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 60 \
--statistics Maximum
If your MCP deployment expects more than a few dozen concurrent sessions, ECS Fargate (one task, many async sessions) is typically more efficient than Lambda (one invocation per session). Lambda Function URLs are the right choice for low-to-medium concurrency MCP tools, developer-facing endpoints, and event-driven tool executions where sessions are short-lived.
Pattern 2 — Cold start elimination: Provisioned Concurrency on alias, SnapStart for JVM, cost vs ECS Fargate Spot
Cold start latency has two distinct solutions on Lambda, and they target different runtimes. Provisioned Concurrency works for all Lambda runtimes — it pre-initializes N execution environments so that cold starts do not occur for the first N concurrent sessions. Lambda SnapStart works only for Java 21+ runtimes — it takes a memory snapshot after the init phase completes and restores from that snapshot (~250ms) instead of repeating the full init (~5–12s for Spring or Quarkus). The two techniques are composable: SnapStart reduces the cost of provisioning (each provisioned instance is initialized once from the snapshot rather than from scratch), and Provisioned Concurrency eliminates the remaining ~250ms restore latency for SnapStart-based deployments where sub-100ms first-response is required.
Provisioned Concurrency: alias, not $LATEST
The most common Provisioned Concurrency mistake is targeting $LATEST. Provisioned Concurrency cannot be configured on $LATEST — it must be set on a Lambda alias that points to a published version. The CDK pattern: publish a version via currentVersion, create an alias, attach Provisioned Concurrency and the Function URL to the alias.
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as lambdaNodeJs from "aws-cdk-lib/aws-lambda-nodejs";
import * as appscaling from "aws-cdk-lib/aws-applicationautoscaling";
import { Duration } from "aws-cdk-lib";
const mcpFn = new lambdaNodeJs.NodejsFunction(this, "McpFunction", {
entry: "src/lambda-mcp-handler.ts",
runtime: lambda.Runtime.NODEJS_22_X,
timeout: Duration.minutes(5),
memorySize: 512,
});
// currentVersion publishes a new version on each CDK deploy
const version = mcpFn.currentVersion;
// Alias holds the Provisioned Concurrency configuration
const alias = new lambda.Alias(this, "McpAlias", {
aliasName: "live",
version,
provisionedConcurrentExecutions: 2, // floor: 2 always-warm instances
});
// Function URL attaches to the alias — NOT the function ARN
alias.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
invokeMode: lambda.InvokeMode.RESPONSE_STREAM,
});
// Auto Scaling: scale provisioned instances between 2 and 20
const target = new appscaling.ScalableTarget(this, "ScalableTarget", {
serviceNamespace: appscaling.ServiceNamespace.LAMBDA,
resourceId: `function:${mcpFn.functionName}:live`,
scalableDimension: "lambda:function:ProvisionedConcurrency",
minCapacity: 2,
maxCapacity: 20,
});
target.scaleToTrackMetric("PcuTracking", {
targetValue: 0.7, // scale out when 70% of provisioned instances in use
predefinedMetric: appscaling.PredefinedMetric.LAMBDA_PROVISIONED_CONCURRENCY_UTILIZATION,
scaleInCooldown: Duration.minutes(5),
scaleOutCooldown: Duration.seconds(30), // aggressive scale-out for traffic spikes
});
Watch two CloudWatch metrics to validate the configuration: ProvisionedConcurrencyUtilization (percentage of provisioned instances currently serving requests — alert above 80%) and ProvisionedConcurrencySpilloverInvocations (invocations that exceeded provisioned capacity and fell back to on-demand cold starts — any non-zero value means your auto-scaling policy is too slow for your actual traffic pattern).
SnapStart for Java MCP servers
For teams running Java-based MCP servers (Spring Boot, Quarkus, or the Kotlin MCP SDK), SnapStart eliminates the 5–12 second JVM init cold start. The mechanism: after the first deployment, Lambda runs the init phase (class loading, DI container startup, SDK initialization), fires a beforeCheckpoint event, takes an encrypted memory snapshot of the JVM heap and class data, and stores it in a regional cache. Subsequent cold starts restore from this snapshot in ~250ms and fire afterRestore.
The CRaC hooks are not optional — they are the correctness boundary. The snapshot captures network connection state that is invalid after restore (stale TCP connections), cryptographic state that is predictable across all restored instances if not reseeded (SecureRandom), and time-dependent state that has elapsed by restore time (TTL counters, credential expiry timestamps).
// build.gradle.kts
dependencies {
implementation("io.github.crac:org-crac:0.1.3")
implementation("software.amazon.awssdk:lambda:2.21.0")
}
// McpHandler.java
import org.crac.Context;
import org.crac.Core;
import org.crac.Resource;
import java.security.SecureRandom;
public class McpHandler implements Resource {
private SecretsManagerClient secretsClient;
private DatabaseConnectionPool dbPool;
private SecureRandom secureRandom;
public McpHandler() {
this.secretsClient = SecretsManagerClient.create();
this.dbPool = DatabaseConnectionPool.create(loadDbUrl());
this.secureRandom = new SecureRandom();
Core.getGlobalContext().register(this); // register for CRaC events
}
@Override
public void beforeCheckpoint(Context<? extends Resource> context) throws Exception {
// Close all network connections — stale TCP state is invalid after restore
dbPool.closeAll();
secretsClient.close();
// Do NOT touch secureRandom here — reseed in afterRestore
}
@Override
public void afterRestore(Context<? extends Resource> context) throws Exception {
// Re-open connections with fresh state
secretsClient = SecretsManagerClient.create();
dbPool = DatabaseConnectionPool.create(loadDbUrl()); // re-fetch credentials
// Critical: reseed SecureRandom — snapshot seed is shared across all restored instances
secureRandom = new SecureRandom();
secureRandom.nextBytes(new byte[32]); // force entropy collection
// Reset any timestamps/TTL counters set during init
initTimestamp = System.currentTimeMillis();
}
}
Enable SnapStart in CDK with snapStart: lambda.SnapStartConf.ON_PUBLISHED_VERSIONS. It only activates on published versions — $LATEST still goes through full init. This means every code deployment triggers one full init (for the snapshot), and all subsequent cold starts restore from that snapshot.
const mcpFn = new lambda.Function(this, "McpJavaFunction", {
runtime: lambda.Runtime.JAVA_21,
handler: "com.example.McpHandler::handleRequest",
code: lambda.Code.fromAsset("target/mcp-server.jar"),
timeout: Duration.minutes(5),
memorySize: 1024, // more memory speeds class loading even without SnapStart
snapStart: lambda.SnapStartConf.ON_PUBLISHED_VERSIONS,
});
Cost model: when ECS Fargate Spot wins
Provisioned Concurrency is billed continuously at a slightly lower rate than on-demand — but the charge runs even at zero traffic. This makes it more expensive than on-demand at low traffic and cost-neutral at sustained load:
| Pricing component | On-demand Lambda | Provisioned Concurrency | ECS Fargate Spot (0.5 vCPU / 1 GB) |
|---|---|---|---|
| Always-on cost per instance (512 MB, 24/7) | $0 (no invocations = no charge) | ~$19.44/month per provisioned instance | ~$7.35/month per task (0.5 vCPU / 1 GB Spot) |
| Execution duration rate | $0.0000166667/GB-second | $0.0000097/GB-second (lower) | $0.0101334/vCPU-hour + $0.0111345/GB-hour |
| Cold start | 300ms–1s (Node.js), 5–12s (JVM) | ~0ms (pre-initialized) | ~0ms (long-running process) |
| Concurrency model | One invocation per request | One invocation per request | One process, many async sessions |
| Idle cost at zero traffic | $0 | Billed regardless | Billed regardless (Spot can reclaim) |
Decision rule: use Provisioned Concurrency for bursty MCP traffic (a few peak hours per day, long quiet periods) where cold starts during the burst are unacceptable and sustained 24/7 cost is not a concern. Switch to ECS Fargate Spot when you need ≥2 always-warm instances 24/7 — a single Fargate Spot task at 0.5 vCPU / 1 GB ($7.35/month) is cheaper than one provisioned Lambda instance ($19.44/month) and handles hundreds of concurrent sessions through Node.js async I/O.
Pattern 3 — Container images: layer ordering for cache efficiency, base image selection, arm64 Graviton savings, ECR regional constraint
The Lambda zip deployment limit is 250 MB (uncompressed). Most Node.js MCP servers fit comfortably within this — the MCP SDK, Zod, and typical tool dependencies are well under 100 MB. Container images become necessary when the dependency tree includes native addon modules (sharp, canvas, better-sqlite3, bcrypt), ML model files, or any binary that requires a specific glibc version and must be compiled inside the container.
Three things determine whether your container image cold starts are acceptable: the base image choice, the Dockerfile layer order, and the ECR region.
Dockerfile layer ordering: the cache hit strategy
Lambda maintains an internal image layer cache per region. When a cold start occurs, Lambda checks each image layer's SHA256 digest against the regional cache. Layers in cache are mounted without re-pulling from ECR — typically under 200ms per layer. Layers not in cache are pulled from ECR, adding 1–10 seconds depending on layer size.
The critical ordering rule: run npm ci --omit=dev before COPY src/. If you copy source files first and then install dependencies, the dependency layer changes every time any source file changes — even a one-line comment edit invalidates the multi-hundred-MB dependency layer and forces a cache miss on every deploy.
# Stage 1: install dependencies in a full Node.js image
FROM node:22-slim AS builder
WORKDIR /app
# Copy lock files first — this layer only changes when dependencies change
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copy TypeScript source and build — this layer changes on every code change (small layer)
COPY tsconfig.json ./
COPY src/ ./src/
RUN npx tsc --outDir dist
# Stage 2: Lambda runtime image (AWS base image)
# public.ecr.aws/lambda/nodejs:22 is cached across all Node.js Lambdas in the region
FROM public.ecr.aws/lambda/nodejs:22
COPY --from=builder /app/dist/ ${LAMBDA_TASK_ROOT}/
COPY --from=builder /app/node_modules/ ${LAMBDA_TASK_ROOT}/node_modules/
CMD [ "handler.handler" ]
Use the AWS base image (public.ecr.aws/lambda/nodejs:22) rather than a custom base. This image is shared across thousands of Lambda functions in each region and is almost always in Lambda's regional cache. Building on Alpine or a custom Debian base means your OS layers will never be in cache — adding a cache miss penalty on the first cold start after every image change.
The Alpine musl trap: Alpine Linux uses musl libc. Lambda's execution environment uses glibc (Amazon Linux 2023). Native modules compiled against musl libc fail at runtime with "invalid ELF header" or "cannot open shared object file" errors. Alpine-built images work in Docker locally (where Alpine is the host environment) and fail silently on Lambda. Always use the AWS base image or Amazon Linux 2023 as the final stage base for Lambda container images.
arm64 Graviton: 20% lower cost, equivalent Node.js performance
Lambda on arm64 (Graviton2) costs approximately 20% less than x86_64 at equivalent performance for Node.js workloads. The only requirement: build the container image for linux/arm64 and set architecture: lambda.Architecture.ARM_64 in CDK.
# Build for arm64 (Lambda Graviton — 20% cheaper than x86_64)
REGION=us-east-1
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REPO=mcp-server
IMAGE_TAG=$(git rev-parse --short HEAD)
aws ecr get-login-password --region $REGION | \
docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
docker buildx build --platform linux/arm64 \
-t $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO:$IMAGE_TAG \
--push .
# CDK: pin to image digest for reproducibility (not :latest tag)
const mcpFn = new lambda.DockerImageFunction(this, "McpFunction", {
code: lambda.DockerImageCode.fromEcr(repo, { tagOrDigest: "sha256:abc123..." }),
architecture: lambda.Architecture.ARM_64, // Graviton: 20% cheaper
timeout: Duration.minutes(5),
memorySize: 512,
});
repo.grantPull(mcpFn.role!);
ECR regional constraint
Lambda cannot pull a container image from an ECR repository in a different region. A Lambda function in us-east-1 must pull from an ECR repository in us-east-1. This constraint is invisible until you add a second deployment region: the CI pipeline that pushes to one ECR repository will cause Lambda deployments in other regions to fail with ResourceNotFoundException: image not found.
For multi-region deployments, either push the image to ECR in each region separately in the CI pipeline, or configure ECR cross-region replication on the primary repository:
# Push to multiple regions in CI (buildspec.yml or GitHub Actions)
for REGION in us-east-1 eu-west-1 ap-southeast-1; do
aws ecr get-login-password --region $REGION | \
docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
docker tag $SOURCE_IMAGE $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO:$IMAGE_TAG
docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO:$IMAGE_TAG
done
If the Lambda Insights extension is enabled, verify that the extension layer architecture matches the function architecture. The console's Lambda Insights enablement wizard selects the correct architecture automatically; manual ARN configuration does not — an x86_64 extension layer on an arm64 function silently fails or causes init errors.
Pattern 4 — Edge distribution: Lambda@Edge geo-routing, CloudFront Functions for auth, the three hard edge constraints
Lambda@Edge runs your code at CloudFront edge locations — hundreds of points of presence globally — but it is not a viable MCP server execution environment. Three hard constraints make it impossible to run full MCP tool logic at the edge: the 128 MB memory limit (the MCP SDK plus tool handlers typically exceed this; regional Lambda allows up to 10,240 MB), no VPC access (all the databases, caches, and internal services your MCP tools query are private and unreachable from edge functions), and the 5-second timeout on viewer-facing triggers (MCP tool calls routinely run 5–60 seconds).
The correct mental model: run the MCP server itself in regional Lambda (with Function URLs) or ECS. Put CloudFront in front for the custom domain and static asset caching. Use Lambda@Edge or CloudFront Functions at the edge for three specific tasks — and only those three.
| Capability | CloudFront Functions | Lambda@Edge (viewer trigger) | Lambda@Edge (origin trigger) | Regional Lambda |
|---|---|---|---|---|
| Max timeout | 1ms | 5s | 30s | 900s |
| Max memory | 2 MB | 128 MB | 128 MB | 10,240 MB |
| VPC access | No | No | No | Yes |
| Environment variables | No | No | No | Yes |
| Network calls | No | Yes (within 5s) | Yes (within 30s) | Yes |
| Run MCP tool execution | No | No | No | Yes |
| JWT format validation | Yes | Yes | Yes | Not needed here |
| Geo-routing to nearest origin | No (no network) | No (timeout) | Yes | Not applicable |
CloudFront Functions: sub-millisecond token format rejection
CloudFront Functions run at sub-millisecond latency at every edge location. They cannot make network calls (no JWKS fetch), but they can validate JWT structure — three base64url-encoded segments separated by dots. Requests with a malformed or missing bearer token never reach your Lambda origin, saving Lambda concurrency for legitimate sessions.
// cloudfront-mcp-auth.js — CloudFront Function (viewer-request trigger)
// Validates JWT format; full signature verification happens at origin
function handler(event) {
var request = event.request;
// Only protect MCP endpoint paths
if (!request.uri.startsWith('/mcp')) {
return request;
}
var authHeader = request.headers['authorization']
? request.headers['authorization'].value : '';
if (!authHeader.startsWith('Bearer ')) {
return {
statusCode: 401,
statusDescription: 'Unauthorized',
headers: {
'content-type': { value: 'application/json' },
'cache-control': { value: 'no-store' },
},
body: JSON.stringify({ error: 'Missing Authorization header' }),
};
}
var token = authHeader.slice(7);
var parts = token.split('.');
if (parts.length !== 3) {
return {
statusCode: 401,
statusDescription: 'Unauthorized',
headers: { 'content-type': { value: 'application/json' } },
body: JSON.stringify({ error: 'Malformed token' }),
};
}
return request; // pass to origin for full signature validation
}
Lambda@Edge origin-request trigger: geo-routing to nearest regional MCP origin
If you deploy MCP servers in multiple AWS regions, a Lambda@Edge origin-request trigger can inspect the viewer's country header and route to the nearest regional Lambda Function URL, reducing round-trip latency. The origin-request trigger has a 30-second timeout and can make HTTPS calls — sufficient for routing decisions and JWKS caching.
// lambda-edge-geo-router.js — Lambda@Edge (origin-request trigger)
// Must be deployed in us-east-1 — CloudFront replicates to all edge locations
exports.handler = async (event) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// CloudFront-Viewer-Country header requires explicit inclusion in origin request policy
const viewerCountry = headers['cloudfront-viewer-country']?.[0]?.value ?? 'US';
const regionMap = {
// Europe → eu-west-1
'GB': 'eu-west-1', 'DE': 'eu-west-1', 'FR': 'eu-west-1', 'NL': 'eu-west-1',
// Nordic → eu-north-1
'SE': 'eu-north-1', 'FI': 'eu-north-1', 'NO': 'eu-north-1',
// Asia Pacific
'JP': 'ap-northeast-1', 'AU': 'ap-southeast-2',
'SG': 'ap-southeast-1', 'IN': 'ap-south-1',
};
const region = regionMap[viewerCountry] ?? 'us-east-1';
const originDomains = {
'us-east-1': 'abc123.lambda-url.us-east-1.on.aws',
'eu-west-1': 'def456.lambda-url.eu-west-1.on.aws',
'ap-southeast-1': 'ghi789.lambda-url.ap-southeast-1.on.aws',
'ap-northeast-1': 'jkl012.lambda-url.ap-northeast-1.on.aws',
};
const targetDomain = originDomains[region] ?? originDomains['us-east-1'];
request.origin = {
custom: {
domainName: targetDomain,
port: 443,
protocol: 'https',
readTimeout: 60, // extend for slow MCP tool calls
keepaliveTimeout: 5,
sslProtocols: ['TLSv1.2'],
customHeaders: {},
}
};
request.headers['host'] = [{ key: 'Host', value: targetDomain }];
return request;
};
Deployment constraint: Lambda@Edge functions must be deployed to us-east-1 regardless of where your origins are. CloudFront replicates the function to all edge locations automatically. Use the cloudfront.experimental.EdgeFunction CDK construct — it enforces the us-east-1 requirement at synthesis time.
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
const geoRouter = new cloudfront.experimental.EdgeFunction(this, "GeoRouter", {
runtime: lambda.Runtime.NODEJS_22_X,
handler: "lambda-edge-geo-router.handler",
code: lambda.Code.fromAsset("src/edge"),
// No VPC, no environment variables — Lambda@Edge hard constraints
// Embed configuration as constants; do not use process.env
});
const distribution = new cloudfront.Distribution(this, "McpDistribution", {
defaultBehavior: {
origin: new origins.HttpOrigin("abc123.lambda-url.us-east-1.on.aws", {
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
readTimeout: Duration.seconds(60),
}),
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
compress: false, // never compress SSE streams
edgeLambdas: [
{
functionVersion: geoRouter.currentVersion,
eventType: cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
},
],
},
domainNames: ["your-mcp-domain.com"],
certificate: acmCert,
});
Environment variable constraint: Lambda@Edge functions do not support environment variables. This is a hard platform constraint, not a configuration oversight. Embed configuration as constants in the function code, or fetch from SSM Parameter Store at function initialization time (module-level code runs once per cold start and survives warm invocations). Do not use process.env — the values will be undefined at runtime even if you configure them in CDK.
Combined failure mode reference table
| Symptom | Pattern | Cause | Fix |
|---|---|---|---|
| SSE stream cut at exactly 29 seconds | Function URLs | API Gateway HTTP API in front of Lambda; 29-second hard integration timeout | Switch to Lambda Function URL with invokeMode: RESPONSE_STREAM; or use direct Function URL access through CloudFront |
| Client receives all SSE events at once at end of tool call | Function URLs | invokeMode is BUFFERED (default); handler does not use streamifyResponse | Set invokeMode: lambda.InvokeMode.RESPONSE_STREAM; wrap handler in streamifyResponse |
| CORS preflight fails (OPTIONS returns 403) | Function URLs | CORS configured inside the streaming handler, not on the Function URL configuration | Move cors block to addFunctionUrl call; Function URL layer handles OPTIONS automatically |
| HTTP 429 throttling during traffic spike | Function URLs | Concurrent sessions hit account reserved concurrency limit (default 1,000) | Request concurrency limit increase; set reservedConcurrentExecutions on function; add client retry with exponential backoff |
| Provisioned Concurrency set but cold starts still happen | Cold starts | Function URL or invocation targeting $LATEST, not the alias with Provisioned Concurrency | Attach Function URL to the alias ARN (:live); verify with aws lambda get-alias --name live |
ProvisionedConcurrencySpilloverInvocations non-zero | Cold starts | Traffic spike exceeds provisioned count before auto-scaling responds | Lower scaleOutCooldown; increase minCapacity; use scheduled scaling for predictable traffic ramps |
| Cold starts still 5–10s after enabling SnapStart | Cold starts | Invocations targeting $LATEST; SnapStart does not activate on $LATEST | Target the published version alias; verify alias points to a version (not $LATEST) |
Connection refused on first DB call after SnapStart restore | Cold starts | DB connection opened during init captured in snapshot; stale after restore | Implement beforeCheckpoint to close pool; afterRestore to re-open it |
| Predictable "random" tokens across SnapStart-restored instances | Cold starts | SecureRandom seed captured in snapshot; all instances share the seed state | Create new SecureRandom in afterRestore; call nextBytes(32) to force entropy collection |
| Cold start suddenly 5–10× slower after image deploy | Container images | Dockerfile layer ordering changed — large dependency layer is now after COPY src/ and misses cache on every deploy | Move npm ci before COPY src/ so dependency layer only rebuilds when package-lock.json changes |
exec format error on Lambda invoke | Container images | Image built for wrong architecture (amd64 when Lambda function is ARM_64) | Use docker buildx build --platform linux/arm64; verify with docker inspect before push |
ResourceNotFoundException: image not found during deployment | Container images | ECR image in different region than Lambda function | Push image to ECR in each deployment region, or configure ECR cross-region replication |
| Native modules fail on Lambda (works in Docker locally) | Container images | Image built on Alpine (musl libc); Lambda uses glibc (Amazon Linux 2023) — incompatible binaries | Use public.ecr.aws/lambda/nodejs:22 as final stage base; never use Alpine for native modules |
Lambda@Edge function fails to deploy — InvalidLambdaFunctionAssociation | Edge | Lambda@Edge function deployed in a region other than us-east-1 | Use cloudfront.experimental.EdgeFunction construct; it always creates in us-east-1 |
| SSE stream terminates at 5 seconds from edge | Edge | Viewer-request Lambda@Edge trigger has a 5-second timeout; MCP session exceeds it | Use origin-request trigger (30s) for logic near the MCP connection; move auth to CloudFront Functions (<1ms, no timeout) |
| Geo-routing sends EU users to US origin | Edge | cloudfront-viewer-country header not enabled in origin request policy | Add CloudFrontViewerCountry to the CloudFront origin request policy; the header is only populated when explicitly included |
| Environment variables undefined in Lambda@Edge function | Edge | Lambda@Edge does not support environment variables — hard platform constraint | Embed configuration as constants in function code; or fetch from SSM at module initialization time |
| Lambda Insights extension fails with architecture mismatch | Container images | x86_64 Insights extension layer on arm64 Lambda function | Use arm64 variant of the Insights extension ARN; or enable via CloudWatch console which selects correct architecture automatically |
Monitoring Lambda-hosted MCP servers with AliveMCP
Lambda's operational visibility model differs from ECS in ways that create monitoring blind spots for MCP servers. Lambda CloudWatch metrics are per-function, not per-session — you see aggregate invocation count, duration, and error count, but not individual session health. An MCP session that connects and then hangs (tool call waiting on a downstream timeout, SSE stream abandoned by the client but not closed by the handler) consumes a Lambda invocation for its entire lifetime and disappears from your metrics only when the function times out.
AliveMCP monitors the HTTP endpoint that your MCP clients actually connect to. For Lambda-hosted MCP servers, this means probing the CloudFront URL or Function URL directly — validating that the endpoint accepts connections, returns the correct SSE content-type, and responds to a tool list request within a configurable threshold. This catches four Lambda-specific failure modes that CloudWatch misses:
- Function URL mis-configuration — CORS set incorrectly,
invokeModeleft on BUFFERED, or auth pattern wrong. AliveMCP catches these because the probe exercises the same HTTP path as your MCP clients. - Provisioned Concurrency depletion — when
ProvisionedConcurrencySpilloverInvocationsis non-zero, some sessions cold-start. AliveMCP latency data distinguishes warm invocations from cold ones at the HTTP layer. - Regional origin failure in geo-distributed deployments — AliveMCP probes from multiple global locations. A us-east-1 Lambda failure detected by US probes while EU probes confirm the eu-west-1 origin is healthy gives you precise failure scope rather than a binary up/down alert.
- Edge function regression — a Lambda@Edge deployment that breaks geo-routing (e.g., missing
cloudfront-viewer-countryin origin request policy) shows up as probes from the expected region hitting the wrong origin and returning different latency or error responses.
Track your MCP endpoint at alivemcp.com — one probe URL, global coverage, alerts before your users notice.