Guide · AWS Networking
MCP Server CloudFront — cache behaviors, SSE passthrough, WebSocket, security headers
CloudFront can dramatically reduce ALB cost and latency for MCP servers that serve both static content (SEO pages, blog, landing page) and dynamic API or SSE streams. The critical insight is that CloudFront is a split system — it caches some paths and passes others through — and getting the split wrong breaks either caching or the protocol. Three common mistakes: using a single CachePolicy that caches everything (SSE and WebSocket paths must never be cached; caching an SSE response at the edge causes CloudFront to buffer the entire stream, breaking the live-event delivery model and returning the buffered response to all clients as if it were static content), enabling compression on SSE or WebSocket cache behaviors (CloudFront's Compress: true setting buffers response bodies to compress them — this is fundamentally incompatible with streaming; set compress: false on any behavior that forwards to a streaming path), and WAF on CloudFront must be in us-east-1 (CloudFront WebACLs must be created in the us-east-1 region regardless of where the distribution is served — a WAF WebACL created in any other region cannot be associated with a CloudFront distribution).
TL;DR
Create two cache behaviors: one with CachingOptimized policy for /seo/*, /blog/*, and /assets/* paths; one with CachingDisabled policy for /mcp, /api/*, and /health paths. Set allowedMethods: ALLOW_ALL and compress: false on the pass-through behavior. Add a security headers response policy (HSTS, X-Frame-Options: DENY, X-Content-Type-Options: nosniff). Run a CloudFront cache invalidation for /* or targeted paths on each deploy.
Cache behavior split: static vs. pass-through
CloudFront evaluates behaviors in order from most-specific path pattern to default (/*). Static content paths are cached at the edge; API and MCP protocol paths bypass the cache and go directly to the ALB origin.
// CDK: CloudFront distribution with split cache behaviors
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
const alb = ...; // your Application Load Balancer
const distribution = new cloudfront.Distribution(this, "McpCdn", {
defaultBehavior: {
// Default: pass through to ALB, no caching (covers /mcp, /api/*, unknown paths)
origin: new origins.LoadBalancerV2Origin(alb, {
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
httpPort: 80,
httpsPort: 443,
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
compress: false, // never compress streaming responses
},
additionalBehaviors: {
// Static SEO pages: cache 24 hours at edge
"/seo/*": {
origin: new origins.LoadBalancerV2Origin(alb, {
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
compress: true,
},
// Blog posts: cache 24 hours
"/blog/*": {
origin: new origins.LoadBalancerV2Origin(alb, {
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
compress: true,
},
// Assets (CSS, JS, SVGs): cache 1 year with immutable headers
"/assets/*": {
origin: new origins.LoadBalancerV2Origin(alb, {
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: new cloudfront.CachePolicy(this, "AssetsCachePolicy", {
defaultTtl: Duration.days(365),
maxTtl: Duration.days(365),
minTtl: Duration.days(365),
}),
compress: true,
},
},
priceClass: cloudfront.PriceClass.PRICE_CLASS_100, // US + EU only; cheapest
// priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL, // global; most expensive
});
WebSocket and SSE passthrough
CloudFront supports WebSocket upgrade and SSE streaming by passing the connection through to the origin. WebSocket works automatically when the origin accepts the Upgrade: websocket header. SSE requires the behavior to have caching disabled and compression off.
| Protocol | CloudFront behavior | Required settings |
|---|---|---|
| HTTP/HTTPS (static) | Caches response at edge | cachePolicy: CACHING_OPTIMIZED, compress: true |
| SSE (Server-Sent Events) | Passes through; must not buffer | cachePolicy: CACHING_DISABLED, compress: false, allowedMethods: ALLOW_ALL |
| WebSocket | Upgrades and proxies TCP | cachePolicy: CACHING_DISABLED, compress: false; CloudFront detects Upgrade: websocket automatically |
| Streamable HTTP (MCP transport) | Passes through | Same as SSE — no buffering, no compression |
// CDK: behavior for MCP SSE/Streamable HTTP path — no caching, no compression
"/mcp": {
origin: new origins.LoadBalancerV2Origin(alb, {
protocolPolicy: cloudfront.OriginProtocolPolicy.HTTPS_ONLY,
// Increase read timeout for long-lived SSE connections
readTimeout: Duration.seconds(60), // default is 30s
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_DISABLED,
originRequestPolicy: cloudfront.OriginRequestPolicy.ALL_VIEWER_EXCEPT_HOST_HEADER,
allowedMethods: cloudfront.AllowedMethods.ALLOW_ALL,
compress: false, // CRITICAL — compression buffers the stream body
},
CloudFront origin read timeout: the default is 30 seconds. Long-lived SSE connections or MCP tool calls that take more than 30 seconds cause CloudFront to close the origin connection and return a 504 to the client. Increase readTimeout to 60s or implement keepalive SSE events every 20s (a comment event like : keepalive\n\n) to reset the idle timer.
Security response headers and cache invalidation
CloudFront response headers policies apply security headers to all responses from the distribution, eliminating the need to add them in your application code.
// CDK: security headers policy + deploy-time invalidation
const securityHeadersPolicy = new cloudfront.ResponseHeadersPolicy(this, "SecurityHeaders", {
securityHeadersBehavior: {
strictTransportSecurity: {
accessControlMaxAge: Duration.days(365),
includeSubdomains: true,
preload: true,
override: true,
},
xFrameOptions: {
frameOption: cloudfront.HeadersFrameOption.DENY,
override: true,
},
xContentTypeOptions: { override: true },
referrerPolicy: {
referrerPolicy: cloudfront.HeadersReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN,
override: true,
},
contentSecurityPolicy: {
contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'",
override: false, // don't override app-set CSP
},
},
});
// Apply headers policy to static behaviors
// (pass-through behaviors for SSE/API can have a lighter policy)
// CDK: invalidate CloudFront cache on each deploy
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
// Or via CloudFormation custom resource:
new cr.AwsCustomResource(this, "CacheInvalidation", {
onCreate: {
service: "CloudFront",
action: "createInvalidation",
parameters: {
DistributionId: distribution.distributionId,
InvalidationBatch: {
CallerReference: Date.now().toString(),
Paths: { Quantity: 1, Items: ["/seo/*"] },
},
},
physicalResourceId: cr.PhysicalResourceId.of("invalidation"),
},
policy: cr.AwsCustomResourcePolicy.fromSdkCalls({ resources: cr.AwsCustomResourcePolicy.ANY_RESOURCE }),
});
Cache invalidation cost: the first 1,000 invalidation paths per month are free; additional paths cost $0.005 each. Invalidating /* counts as one path (wildcard). Invalidating /seo/mcp-server-vpc and /seo/mcp-server-alb individually counts as two paths. Prefer wildcard invalidations (/seo/*) on deploy rather than per-file invalidations.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| SSE client receives one burst of events then connection closes | CloudFront buffered the SSE response body before forwarding (compression enabled, or cache policy is not CACHING_DISABLED) | Set compress: false and cachePolicy: CACHING_DISABLED on the SSE behavior path |
| CloudFront returns 504 on long MCP tool calls | Origin read timeout (default 30s) exceeded by a slow tool execution | Increase readTimeout to 60s on the ALB origin, or send SSE keepalive events every 20s to prevent idle timeout |
| WAF WebACL association fails with "WAF web ACL not found" | WAF WebACL was created in a region other than us-east-1 | Recreate the WAF WebACL with scope: CLOUDFRONT in us-east-1; CloudFront WAFs are global and must be in us-east-1 |
| Deployed new SEO page but CloudFront still serves old version | Cache TTL has not expired; invalidation not run | Run aws cloudfront create-invalidation --paths "/seo/*" after each deploy |
| WebSocket connection refused by CloudFront | Origin protocol policy is HTTP_ONLY but the ALB listener is HTTPS | Set origin protocolPolicy: HTTPS_ONLY to match the ALB HTTPS listener |
| Security headers visible in some responses but not others | Security headers policy only applied to some behaviors, not the default behavior | Apply the response headers policy to every behavior in additionalBehaviors and to defaultBehavior |