Guide · AWS CloudFront
MCP Server CloudFront Functions — URL normalization, security headers, A/B routing at edge
CloudFront Functions run lightweight JavaScript at the CloudFront edge before a request reaches the cache or the origin — and before a response reaches the client — with sub-millisecond execution time and no cold start. They are not Lambda@Edge: they run in a restricted JavaScript runtime (ES5.3 + limited ES6), have a 2ms CPU time limit per invocation, cannot make network calls, and cannot access environment variables. But for the use cases they cover — URL normalization, query string sorting, security header injection, and simple request routing — they are faster and cheaper than Lambda@Edge. For MCP server deployments, the canonical use cases are: canonicalizing /seo/mcp-server-foo/ to /seo/mcp-server-foo (trailing slash removal for SEO), normalizing query parameter order for better cache hit rates, and injecting security response headers.
TL;DR
Use CloudFront Functions (not Lambda@Edge) for stateless, sub-2ms operations: URL normalization, query param sorting, security headers, simple redirects. Use Lambda@Edge when you need network access (DynamoDB lookup, Secrets Manager), environment variables, Node.js modules, or more than 2ms of CPU. Deploy Functions as viewer-request (to normalize the cache key before CloudFront checks the cache) or viewer-response (to add security headers to every response). Functions deployed at viewer-request run on every request including cache hits — keep them fast. A trailing slash normalizer at viewer-request is the single highest-value CloudFront Function for MCP server SEO pages.
CloudFront Functions vs Lambda@Edge: decision guide
| Requirement | CloudFront Functions | Lambda@Edge |
|---|---|---|
| CPU time limit | 2ms per invocation | 30s (viewer) / 30s (origin) |
| Memory limit | 2 MB | 128 MB (viewer), 10 GB (origin-request) |
| Network calls | Not supported | Full Node.js HTTP/HTTPS, AWS SDK |
| Environment variables | Not supported | Supported (origin-request/origin-response) |
| Runtime | CloudFront-restricted JS (ES5.3 + some ES6) | Node.js 18.x / 20.x, Python 3.12 |
| npm modules | Not supported | Full npm support |
| Execution events | viewer-request, viewer-response | viewer-request, origin-request, origin-response, viewer-response |
| Pricing (per 1M invocations) | $0.10 | $0.60 (viewer) / $0.60 (origin) |
| Cold start | None — always warm | Yes (first invocation in a PoP) |
| Deployment region | Global (deploy once, runs everywhere) | Must be us-east-1; replicates globally |
URL normalization: trailing slash removal for SEO canonicalization
MCP server SEO pages at /seo/mcp-server-foo and /seo/mcp-server-foo/ are treated as different URLs by search engines — unless CloudFront redirects one to the other. A viewer-request Function that removes trailing slashes (except for the root /) ensures all traffic hits the canonical URL.
// CloudFront Function — viewer-request event
// Removes trailing slashes and normalizes query parameter order for better cache hit rate
function handler(event) {
var request = event.request;
var uri = request.uri;
// 1. Remove trailing slash (except root path /)
if (uri !== "/" && uri.endsWith("/")) {
var qs = "";
// Preserve query string in redirect
if (request.querystring) {
var params = [];
for (var key in request.querystring) {
var val = request.querystring[key];
if (val.multiValue) {
for (var i = 0; i < val.multiValue.length; i++) {
params.push(key + "=" + encodeURIComponent(val.multiValue[i].value));
}
} else {
params.push(key + "=" + encodeURIComponent(val.value));
}
}
// Sort params for consistent cache key
params.sort();
if (params.length > 0) qs = "?" + params.join("&");
}
return {
statusCode: 301,
statusDescription: "Moved Permanently",
headers: {
location: { value: uri.slice(0, -1) + qs },
"cache-control": { value: "public, max-age=31536000" }, // cache the redirect
},
};
}
// 2. Normalize query string order (sort params alphabetically for better cache hit rate)
// This runs on non-redirect requests (no trailing slash)
if (request.querystring) {
var sortedParams = [];
var keys = Object.keys(request.querystring).sort();
for (var k = 0; k < keys.length; k++) {
var key = keys[k];
sortedParams.push({ name: key, value: request.querystring[key] });
}
// Rebuild querystring object in sorted order
var newQs = {};
for (var j = 0; j < sortedParams.length; j++) {
newQs[sortedParams[j].name] = sortedParams[j].value;
}
request.querystring = newQs;
}
return request;
}
Deploy this Function at the viewer-request event on all cache behaviors that serve SEO pages. The Function runs on every request — including cache hits — before CloudFront checks its cache, so the normalized URL is always used as the cache key.
Security header injection at viewer-response
Security response headers (HSTS, CSP, X-Frame-Options) must be present on every response — including responses served from CloudFront's edge cache. A viewer-response Function is the right place to add these headers because it runs on both cache hits and cache misses, without the complexity and cost of Lambda@Edge.
// CloudFront Function — viewer-response event
// Injects security headers into every response
function handler(event) {
var response = event.response;
var headers = response.headers;
// Strict-Transport-Security: 1 year, includeSubDomains, preload
headers["strict-transport-security"] = {
value: "max-age=31536000; includeSubDomains; preload"
};
// Content-Security-Policy — adjust for MCP server's actual resource origins
headers["content-security-policy"] = {
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'", // remove unsafe-inline once you have hashes
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self' https://alivemcp.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; ")
};
// Prevent MIME type sniffing
headers["x-content-type-options"] = { value: "nosniff" };
// Clickjacking protection (redundant with frame-ancestors CSP but belt-and-suspenders)
headers["x-frame-options"] = { value: "DENY" };
// Referrer policy — don't leak paths in cross-origin requests
headers["referrer-policy"] = { value: "strict-origin-when-cross-origin" };
// Permissions policy — disable features not used by MCP server
headers["permissions-policy"] = {
value: "camera=(), microphone=(), geolocation=(), payment=()"
};
return response;
}
// Note: viewer-response Functions cannot modify the status code
// If you need conditional redirects, use viewer-request instead
A/B test routing: weighted cookie-based split
For MCP server landing page experiments (new pricing copy, hero messaging, CTA color), a viewer-request Function can implement a weighted A/B split without Lambda@Edge or any origin-side logic. The Function assigns a variant cookie on first visit and routes to different origin paths based on the cookie.
// CloudFront Function — viewer-request
// A/B routing: 50% to /landing-v2/, 50% to / (control)
function handler(event) {
var request = event.request;
var cookies = request.headers.cookie ? parseCookies(request.headers.cookie.value) : {};
// Only A/B test the root path
if (request.uri !== "/" && !request.uri.startsWith("/?")) {
return request;
}
var variant = cookies["ab-variant"];
if (!variant) {
// Assign variant — CloudFront Functions cannot set cookies on request events
// Instead, redirect to a path that sets the cookie (origin-side), or
// encode variant in a query param for the origin to handle
// Simple approach: route based on viewer country or random URI-encoded decision
// Math.random() is available in CloudFront Functions runtime
variant = Math.random() < 0.5 ? "control" : "treatment";
}
if (variant === "treatment") {
// Rewrite URI to treatment landing page
request.uri = "/landing-v2/";
// Add custom header to let origin distinguish (for analytics)
request.headers["x-ab-variant"] = { value: "treatment" };
}
return request;
}
function parseCookies(cookieHeader) {
var cookies = {};
if (!cookieHeader) return cookies;
cookieHeader.split(";").forEach(function(cookie) {
var parts = cookie.split("=");
var name = parts[0].trim();
var value = parts.slice(1).join("=").trim();
cookies[name] = value;
});
return cookies;
}
// Important: CloudFront Functions cannot set cookies in viewer-request responses.
// To persist the variant assignment, use the origin to set the cookie on the response,
// or use a viewer-response Function combined with a variant header from the origin.
Deploying and testing CloudFront Functions
CloudFront Functions have a development/live stage system — you deploy to DEVELOPMENT, test locally, then promote to LIVE. Unlike Lambda@Edge, there is no lambda:GetFunction or IAM execution role required: Functions are managed entirely within the CloudFront service.
# Create and deploy a CloudFront Function
aws cloudfront create-function \
--name mcp-url-normalizer \
--function-config '{"Comment": "Trailing slash removal and query normalization", "Runtime": "cloudfront-js-2.0"}' \
--function-code fileb://normalizer.js
# Returns: FunctionARN and ETag
# Test the function with a synthetic event (in DEVELOPMENT stage)
aws cloudfront test-function \
--name mcp-url-normalizer \
--stage DEVELOPMENT \
--if-match EXXXXXXXXXXXX \
--event-object '{
"version": "1.0",
"context": { "eventType": "viewer-request" },
"viewer": { "ip": "1.2.3.4" },
"request": {
"method": "GET",
"uri": "/seo/mcp-server-cloudfront/",
"querystring": {},
"headers": {},
"cookies": {}
}
}'
# Response shows the modified request object and any console.log output
# Publish to LIVE stage (required before attaching to distribution)
aws cloudfront publish-function \
--name mcp-url-normalizer \
--if-match EXXXXXXXXXXXX
# Attach to distribution cache behavior (update-distribution required)
# In the CacheBehavior for the SEO paths:
# FunctionAssociations:
# Items:
# - FunctionARN: arn:aws:cloudfront::ACCOUNT_ID:function/mcp-url-normalizer
# EventType: viewer-request
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Function exceeds 2ms CPU limit | CloudFront returns 503 with x-cache: Error from cloudfront; Function killed mid-execution | Test with cloudfront test-function before deploying; profile with console.log timing; remove loops over large datasets |
| ES6+ syntax unsupported in cloudfront-js-1.0 runtime | SyntaxError on function creation; arrow functions, const/let, template literals fail | Use cloudfront-js-2.0 runtime (supports most ES2020) or transpile to ES5 before upload |
| Function not promoted to LIVE stage | Function appears to exist but distribution update fails with "function not in LIVE stage" | Run publish-function command before attaching to distribution; DEVELOPMENT stage is test-only |
| Viewer-response Function cannot change status code | JavaScript error or silent 500 when Function attempts response.statusCode = 301 | Use viewer-request for redirects (return response object with statusCode); viewer-response can only modify headers |
| Security headers applied to SSE/WebSocket paths | SSE Connection breaks if Content-Security-Policy blocks the event stream; WebSocket 403 | Attach viewer-response Function only to cache behaviors serving HTML pages; skip SSE/WebSocket/API behaviors |
| console.log output not visible in production | Debugging in LIVE stage is blind; Function silently fails | Use test-function in DEVELOPMENT stage for all debugging; enable CloudFront standard logging to S3 for production traces (logs don't include Function output but show status codes) |