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).

DimensionAPI Gateway HTTP APILambda Function URL
Max timeout29 seconds (hard limit, cannot be extended)Lambda function timeout — up to 900s
SSE streamingNo — buffers entire response before deliveryYes — RESPONSE_STREAM flushes incrementally
Custom domainNative supportVia CloudFront + origin access control
Auth at gatewayJWT authorizer, IAM, Lambda authorizerIAM (SigV4) or NONE; application-layer auth in handler
Request routingPath and method routing across multiple LambdasSingle function per URL
Request charge$1.00 / million requests + data transferFunction URL requests are free; pay Lambda invocation only
Concurrency modelOne invocation per requestOne 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 componentOn-demand LambdaProvisioned ConcurrencyECS 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 start300ms–1s (Node.js), 5–12s (JVM)~0ms (pre-initialized)~0ms (long-running process)
Concurrency modelOne invocation per requestOne invocation per requestOne process, many async sessions
Idle cost at zero traffic$0Billed regardlessBilled 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.

CapabilityCloudFront FunctionsLambda@Edge (viewer trigger)Lambda@Edge (origin trigger)Regional Lambda
Max timeout1ms5s30s900s
Max memory2 MB128 MB128 MB10,240 MB
VPC accessNoNoNoYes
Environment variablesNoNoNoYes
Network callsNoYes (within 5s)Yes (within 30s)Yes
Run MCP tool executionNoNoNoYes
JWT format validationYesYesYesNot needed here
Geo-routing to nearest originNo (no network)No (timeout)YesNot 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

SymptomPatternCauseFix
SSE stream cut at exactly 29 secondsFunction URLsAPI Gateway HTTP API in front of Lambda; 29-second hard integration timeoutSwitch 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 callFunction URLsinvokeMode is BUFFERED (default); handler does not use streamifyResponseSet invokeMode: lambda.InvokeMode.RESPONSE_STREAM; wrap handler in streamifyResponse
CORS preflight fails (OPTIONS returns 403)Function URLsCORS configured inside the streaming handler, not on the Function URL configurationMove cors block to addFunctionUrl call; Function URL layer handles OPTIONS automatically
HTTP 429 throttling during traffic spikeFunction URLsConcurrent 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 happenCold startsFunction URL or invocation targeting $LATEST, not the alias with Provisioned ConcurrencyAttach Function URL to the alias ARN (:live); verify with aws lambda get-alias --name live
ProvisionedConcurrencySpilloverInvocations non-zeroCold startsTraffic spike exceeds provisioned count before auto-scaling respondsLower scaleOutCooldown; increase minCapacity; use scheduled scaling for predictable traffic ramps
Cold starts still 5–10s after enabling SnapStartCold startsInvocations targeting $LATEST; SnapStart does not activate on $LATESTTarget the published version alias; verify alias points to a version (not $LATEST)
Connection refused on first DB call after SnapStart restoreCold startsDB connection opened during init captured in snapshot; stale after restoreImplement beforeCheckpoint to close pool; afterRestore to re-open it
Predictable "random" tokens across SnapStart-restored instancesCold startsSecureRandom seed captured in snapshot; all instances share the seed stateCreate new SecureRandom in afterRestore; call nextBytes(32) to force entropy collection
Cold start suddenly 5–10× slower after image deployContainer imagesDockerfile layer ordering changed — large dependency layer is now after COPY src/ and misses cache on every deployMove npm ci before COPY src/ so dependency layer only rebuilds when package-lock.json changes
exec format error on Lambda invokeContainer imagesImage 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 deploymentContainer imagesECR image in different region than Lambda functionPush image to ECR in each deployment region, or configure ECR cross-region replication
Native modules fail on Lambda (works in Docker locally)Container imagesImage built on Alpine (musl libc); Lambda uses glibc (Amazon Linux 2023) — incompatible binariesUse public.ecr.aws/lambda/nodejs:22 as final stage base; never use Alpine for native modules
Lambda@Edge function fails to deploy — InvalidLambdaFunctionAssociationEdgeLambda@Edge function deployed in a region other than us-east-1Use cloudfront.experimental.EdgeFunction construct; it always creates in us-east-1
SSE stream terminates at 5 seconds from edgeEdgeViewer-request Lambda@Edge trigger has a 5-second timeout; MCP session exceeds itUse 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 originEdgecloudfront-viewer-country header not enabled in origin request policyAdd CloudFrontViewerCountry to the CloudFront origin request policy; the header is only populated when explicitly included
Environment variables undefined in Lambda@Edge functionEdgeLambda@Edge does not support environment variables — hard platform constraintEmbed configuration as constants in function code; or fetch from SSM at module initialization time
Lambda Insights extension fails with architecture mismatchContainer imagesx86_64 Insights extension layer on arm64 Lambda functionUse 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:

Track your MCP endpoint at alivemcp.com — one probe URL, global coverage, alerts before your users notice.