Guide · AWS CloudFront

MCP Server CloudFront Cache Behaviors — path patterns for API, SSE, and static assets

A CloudFront distribution in front of an MCP server must handle several radically different traffic types: SSE streams that must never be buffered, REST API calls that should bypass the cache entirely, status endpoints that are highly cacheable, static assets with indefinite TTLs, and WebSocket connections that need protocol-level passthrough. CloudFront's ordered cache behaviors let you assign different caching policies, TTLs, and origin protocols to different URL path patterns. The order matters: CloudFront matches from top to bottom and stops at the first matching behavior; a misconfigured order where a wildcard catches API paths before a more-specific behavior is a common source of subtle bugs. This guide walks through the canonical behavior set for MCP server deployments.

TL;DR

Order behaviors from most-specific to least-specific. SSE paths (/sse/*, /stream/*) must come before the default API bypass — set AllowedMethods = GET,HEAD, TTL = 0, and Compress = false (gzip breaks streaming). WebSocket paths need AllowedMethods = GET,HEAD with OriginProtocolPolicy = https-only — CloudFront handles the Upgrade: websocket header automatically for WebSocket protocol. API paths (/api/*) bypass cache with CachingDisabled policy and forward all headers/cookies needed by the origin. Static assets (/assets/*) use a CachingOptimized policy with long TTL and hashed filenames for cache busting. Default behavior catches everything else.

Path pattern precedence: more-specific behaviors first

CloudFront evaluates ordered cache behaviors top-to-bottom and uses the first match. Path patterns support * wildcard (matches zero or more characters) and ? (matches exactly one character). A behavior with path pattern /api/* must appear before a default behavior — but specific API sub-paths like /api/mcp/status/* that need different caching must appear before the general /api/*.

# Recommended ordered behavior list for MCP server (order matters — most specific first):

# Behavior 1: WebSocket upgrade paths (before SSE and API)
PathPattern: /ws/*
AllowedMethods: GET, HEAD
CachedMethods: GET, HEAD
CachePolicyId: CachingDisabled  # managed policy ID: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
OriginRequestPolicyId: AllViewer  # forward all headers including Upgrade:websocket
ViewerProtocolPolicy: https-only
Compress: false  # do not compress WebSocket frames

# Behavior 2: SSE streaming paths (disable all buffering)
PathPattern: /sse/*
AllowedMethods: GET, HEAD
CachedMethods: GET, HEAD
CachePolicyId: CachingDisabled
OriginRequestPolicyId: AllViewerExceptHostHeader
ViewerProtocolPolicy: https-only
Compress: false  # critical — gzip on SSE breaks streaming

# Also add /stream/* if used:
PathPattern: /stream/*
(same settings as /sse/*)

# Behavior 3: Cacheable status endpoint (with narrow cache key)
PathPattern: /api/mcp/status/*
AllowedMethods: GET, HEAD
CachedMethods: GET, HEAD
CachePolicyId: mcp-status-cache-policy   # custom: TTL 60s, only "serverId" in cache key
OriginRequestPolicyId: CORS-S3Origin
ViewerProtocolPolicy: https-only
Compress: true

# Behavior 4: All other API calls (bypass cache — auth, mutations, private data)
PathPattern: /api/*
AllowedMethods: GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE
CachedMethods: GET, HEAD
CachePolicyId: CachingDisabled
OriginRequestPolicyId: AllViewerExceptHostHeader  # forward auth headers
ViewerProtocolPolicy: https-only
Compress: true

# Behavior 5: Static assets with immutable TTL
PathPattern: /assets/*
AllowedMethods: GET, HEAD
CachedMethods: GET, HEAD
CachePolicyId: CachingOptimized   # managed policy: max 31536000s (1 year)
ViewerProtocolPolicy: https-only
Compress: true

# Behavior 6: Default (HTML pages, sitemap, robots.txt)
PathPattern: *  (default behavior)
AllowedMethods: GET, HEAD
CachePolicyId: mcp-html-cache-policy  # custom: TTL 300s, no query string variation
ViewerProtocolPolicy: https-only
Compress: true

SSE streaming: disabling gzip and buffering

Server-Sent Events (SSE) are used by MCP's stdio transport over HTTP. CloudFront's gzip and Brotli compression are incompatible with SSE — the compressor buffers response bytes before flushing, which breaks the streaming delivery model. Clients that open an SSE connection through CloudFront with compression enabled will see no events until the buffer fills (or the connection closes), then receive a burst.

// Origin-side SSE response headers that CloudFront must not modify
app.get("/sse/mcp-tool-stream", (req, res) => {
  // Required headers for SSE — CloudFront must pass these through unchanged
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache, no-store");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no");  // hint for nginx; no effect on CloudFront
  res.flushHeaders(); // flush status + headers immediately before first event

  // Send events
  const interval = setInterval(() => {
    res.write(`data: ${JSON.stringify({ ts: Date.now() })}\n\n`);
  }, 1000);

  req.on("close", () => {
    clearInterval(interval);
  });
});

// Verify SSE is not being compressed: check response headers in browser DevTools
// Headers should show no Content-Encoding: gzip / br
// If you see Content-Encoding, the Compress: true setting is on the wrong behavior

// CloudFront cache behavior for SSE — key settings:
// Compress: false          ← MUST be false for SSE paths
// CachePolicyId: CachingDisabled
// OriginRequestPolicy:
//   HeaderBehavior: allViewerAndWhitelistCloudFront  (to forward auth headers)
//   QueryStringBehavior: all  (forward token/slug in query string)
//   CookieBehavior: none      (SSE auth via query param, not cookie)

WebSocket passthrough: the Upgrade header and protocol negotiation

CloudFront supports WebSocket connections natively — it recognizes the HTTP/1.1 Upgrade: websocket header and establishes a persistent TCP connection between the client and the origin. No special configuration is required beyond ensuring the cache behavior for WebSocket paths uses AllViewer or AllViewerExceptHostHeader as the origin request policy, which forwards the Upgrade and Connection: Upgrade headers.

// WebSocket origin in Node.js (ws library) behind CloudFront
import { WebSocketServer } from "ws";
import http from "http";
import express from "express";

const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: "/ws/mcp" });

wss.on("connection", (ws, req) => {
  // req.headers["x-forwarded-for"] contains the real client IP from CloudFront
  const clientIp = req.headers["x-forwarded-for"]?.toString().split(",")[0];

  ws.on("message", (data) => {
    // Process MCP tool message
    const message = JSON.parse(data.toString());
    const result = processToolCall(message);
    ws.send(JSON.stringify(result));
  });
});

// CloudFront health check: WebSocket origins must also respond to HTTP GET
// CloudFront uses HTTP for health checks, not WebSocket
app.get("/ws/health", (req, res) => res.json({ status: "ok" }));

// Important: CloudFront connection timeout for WebSocket is 200 seconds
// If your MCP tool takes longer, the client must send ping frames to keep the connection alive
// WebSocket ping from client every 60s prevents 200s idle timeout disconnect

API bypass behavior: forwarding auth headers and cookies

Dynamic API paths that return user-specific data (authenticated tool calls, private endpoint data) must bypass the CloudFront cache entirely and forward the request's authorization context to the origin. The origin request policy controls which headers and cookies are forwarded; the cache policy controls what goes into the cache key.

# CloudFormation: custom origin request policy for authenticated API calls
McpApiOriginRequestPolicy:
  Type: AWS::CloudFront::OriginRequestPolicy
  Properties:
    OriginRequestPolicyConfig:
      Name: mcp-api-auth-forward
      HeadersConfig:
        HeaderBehavior: whitelist
        Headers:
          - Authorization          # JWT or API key
          - X-MCP-Tenant-Id        # custom tenant header
          - X-Request-Id           # for distributed tracing
          - CloudFront-Viewer-Country  # injected by CloudFront, useful for rate limiting
      QueryStringsConfig:
        QueryStringBehavior: all   # forward all query params to origin
      CookiesConfig:
        CookieBehavior: none       # API uses header auth, not cookies

# Cache policy for API bypass — nothing is cached
McpApiCachePolicyDisabled:
  Type: AWS::CloudFront::CachePolicy
  Properties:
    CachePolicyConfig:
      Name: mcp-api-no-cache
      DefaultTTL: 0
      MaxTTL: 0
      MinTTL: 0
      ParametersInCacheKeyAndForwardedToOrigin:
        CookiesConfig: { CookieBehavior: none }
        HeadersConfig: { HeaderBehavior: none }
        QueryStringsConfig: { QueryStringBehavior: none }
        EnableAcceptEncodingGzip: false
        EnableAcceptEncodingBrotli: false

Static assets: immutable caching with hash-based filenames

Static assets — JavaScript bundles, CSS, images — that are referenced by hash in their filename (e.g., main.a1b2c3d4.js) can be cached indefinitely. The filename hash changes with every build, so there is no stale-content risk. CloudFront's managed CachingOptimized policy handles this with a max TTL of one year.

# Static asset cache behavior — hash-named files, 1-year TTL
StaticAssetCacheBehavior:
  PathPattern: /assets/*
  ViewerProtocolPolicy: https-only
  AllowedMethods: [GET, HEAD]
  CachedMethods: [GET, HEAD]
  Compress: true  # gzip + brotli on static assets is safe and reduces transfer
  CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6  # CachingOptimized managed policy
  # CachingOptimized defaults: MaxTTL 31536000s, DefaultTTL 86400s
  # Override with custom policy if you need different settings

# Origin response headers for immutable assets (Express):
app.use("/assets", (req, res, next) => {
  // hash in filename = content-addressed = safe to cache forever
  res.set("Cache-Control", "public, max-age=31536000, immutable");
  next();
}, express.static("public/assets"));

# CloudFront cache invalidation on deploy (when asset hash doesn't change — edge case):
aws cloudfront create-invalidation \
  --distribution-id EDFDVBD6EXAMPLE \
  --paths "/assets/main.*.js" "/assets/style.*.css"
# Use glob patterns sparingly — each path counts against the 1000 free invalidations/month

Failure modes reference

FailureSymptomFix
SSE path matched by more general /api/* behavior with Compress=trueSSE clients see no events until CloudFront buffer fills; connection appears to hangAdd /sse/* behavior above /api/* in the ordered list; set Compress=false on SSE behavior
WebSocket paths missing AllViewer origin request policyWebSocket handshake 101 Switching Protocols fails; falls back to regular HTTP 200Set OriginRequestPolicy to AllViewer or AllViewerExceptHostHeader to forward Upgrade and Connection headers
API bypass behavior caches POST responsesStale mutation results served to callers; 200 OK returned for an already-deleted resourceCachingDisabled policy sets TTL=0 for GET; POST/PUT/DELETE are never cached by CloudFront regardless of policy
Authorization header not forwarded to originOrigin receives requests with no auth; returns 401 for all API callsAdd Authorization to the OriginRequestPolicy HeaderBehavior whitelist for API cache behaviors
Default behavior catches API paths due to wrong orderAPI calls hit the default HTML cache policy; responses are cached per-URL with default TTLOrder behaviors: WebSocket → SSE → cached API → API bypass → assets → default; verify in CloudFront console
CachingOptimized on API paths (accidental)Private API responses cached globally and served to other callersAudit cache behavior list; API paths must use CachingDisabled; use path-specific testing (curl with unique query params) to verify