Guide · AWS Lambda · Lambda Layers
Custom Runtimes via Lambda Layers for MCP Servers
A custom runtime is just a Lambda Layer that contains an executable named bootstrap at /opt/bootstrap — when Lambda starts a function configured with Runtime: provided.al2023, it executes /opt/bootstrap instead of a built-in runtime, handing your process the invocation event via environment variables and a local HTTP endpoint. This mechanism lets MCP server developers deploy tools written in Bun (faster cold starts than Node.js), Deno (no node_modules, native TypeScript), Python 3.13 (before AWS adds official support), or any other language with a compiled binary, using exactly the same Lambda infrastructure as any other MCP tool. For shared dependency layers see the shared deps guide; for versioning the runtime layer see the versioning guide.
TL;DR
Create a bootstrap executable that implements the Lambda Runtime API loop: GET /runtime/invocation/next to receive an event, process it, POST to /runtime/invocation/{requestId}/response. Package the runtime binary and the bootstrap script into a ZIP and publish as a layer. Set the function's runtime to provided.al2023 and attach the layer. The function's handler ZIP provides the handler file; the layer provides the interpreter. Build architecture-specific binaries — Bun and Deno have separate downloads for linux-x64 and linux-aarch64.
How custom runtimes work
Lambda's standard runtimes (Node.js, Python, Java) are managed by AWS — the container image for each runtime includes the language interpreter and a runtime shim that handles the invocation loop. Custom runtimes replace this shim with a bootstrap executable that you supply.
The Lambda Runtime API is a simple HTTP interface served on http://${AWS_LAMBDA_RUNTIME_API} inside every execution environment. The contract is:
- At cold start, Lambda executes
/opt/bootstrap(from the layer) or./bootstrap(from the function ZIP if no layer provides it). bootstrapmakes a blocking GET to/2018-06-01/runtime/invocation/next— this call hangs until an invocation arrives.- Lambda responds with the event payload in the body, the request ID in
Lambda-Runtime-Aws-Request-Id, and optional context headers (deadline, function ARN, cognito identity). bootstrapprocesses the event (runs your handler logic).bootstrapPOSTs the response body to/2018-06-01/runtime/invocation/{requestId}/response.- Loop back to step 2 — the same process handles the next invocation (warm path).
The bootstrap file must be executable (chmod +x bootstrap) before it is added to the ZIP. If it is not executable, Lambda returns Runtime.InvalidEntrypoint. Here is the minimal bootstrap shell script that illustrates the contract directly, without any language runtime in the loop — useful for debugging the Runtime API interface or for ultra-lightweight shell-based tools:
#!/bin/sh
# Minimal bootstrap illustrating the Lambda Runtime API contract.
# This shell-only example calls Python for the handler; adapt for any interpreter.
set -euo pipefail
RUNTIME_API="http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime"
HANDLER_FILE="/var/task/handler.py"
while true; do
# Step 1: Block until the next invocation arrives
RESPONSE=$(curl -sS -D /tmp/headers.txt "${RUNTIME_API}/invocation/next")
REQUEST_ID=$(grep -i "Lambda-Runtime-Aws-Request-Id" /tmp/headers.txt \
| tr -d '\r' | awk -F': ' '{print $2}')
# Step 2: Run the handler — write event to a temp file, capture output
echo "$RESPONSE" > /tmp/event.json
if RESULT=$(python3 "$HANDLER_FILE" 2>/tmp/error.txt); then
# Step 3a: Success — POST the response
curl -sS -X POST \
"${RUNTIME_API}/invocation/${REQUEST_ID}/response" \
-H "Content-Type: application/json" \
-d "$RESULT"
else
# Step 3b: Error — POST to the error endpoint
ERROR_MSG=$(cat /tmp/error.txt | head -1)
curl -sS -X POST \
"${RUNTIME_API}/invocation/${REQUEST_ID}/error" \
-H "Content-Type: application/json" \
-d "{\"errorMessage\":\"${ERROR_MSG}\",\"errorType\":\"HandlerError\"}"
fi
done
In practice, higher-level runtimes like Bun and Deno implement the Runtime API loop internally — you do not need to write this loop yourself. The shell-level loop above is only needed when there is no higher-level runtime adapter available for your language.
Bun custom runtime layer
Bun is a JavaScript/TypeScript runtime designed for speed. For MCP tool workloads — JSON parsing, AWS SDK calls, schema validation — Bun's cold start is typically 3–4x faster than Node.js because Bun's JavaScriptCore engine (vs V8) initializes faster and Bun bundles its standard library into the binary rather than loading CommonJS modules from disk.
Build the Bun runtime layer:
#!/bin/bash
# Build a Bun custom runtime Lambda Layer
# Run this on a Linux x86_64 machine or in a Docker container
BUN_VERSION="1.1.34" # pin to a specific version for reproducibility
ARCH="linux-x64" # use "linux-aarch64" for arm64 / Graviton functions
LAYER_DIR="bun-layer"
mkdir -p "${LAYER_DIR}/bin"
# Download the Bun binary for the target architecture
curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-${ARCH}.zip" \
-o bun.zip
unzip -q bun.zip
mv "bun-${ARCH}/bun" "${LAYER_DIR}/bin/bun"
chmod +x "${LAYER_DIR}/bin/bun"
# Create the bootstrap script that Lambda will execute at cold start
cat > "${LAYER_DIR}/bootstrap" << 'BOOTSTRAP'
#!/bin/sh
# Add Bun to PATH and execute the handler file.
# Bun natively implements the Lambda Runtime API when run as a Lambda handler
# via the @bun-community/lambda adapter bundled in the handler ZIP.
export PATH="/opt/bin:$PATH"
exec /opt/bin/bun /var/task/handler.ts
BOOTSTRAP
chmod +x "${LAYER_DIR}/bootstrap"
# Package into a ZIP — the layer ZIP root becomes /opt/ in the execution environment
cd "${LAYER_DIR}"
zip -r9 ../bun-runtime-layer.zip .
cd ..
echo "Layer ZIP: bun-runtime-layer.zip"
ls -lh bun-runtime-layer.zip
# Verify the bootstrap is executable and in the right place
unzip -l bun-runtime-layer.zip | grep bootstrap
The bootstrap script adds /opt/bin to the PATH so that the Bun binary is accessible, then execs Bun with the handler file at /var/task/handler.ts. The /var/task/ directory contains the function's deployment ZIP — your handler TypeScript file lives there, separate from the runtime layer.
Publish the layer and create a function using it:
# Publish the Bun runtime layer to Lambda
LAYER_ARN=$(aws lambda publish-layer-version \
--layer-name bun-runtime \
--description "Bun v1.1.34 custom runtime for Lambda" \
--zip-file fileb://bun-runtime-layer.zip \
--compatible-runtimes provided.al2023 \
--compatible-architectures x86_64 \
--region us-east-1 \
--query 'LayerVersionArn' \
--output text)
echo "Bun layer ARN: $LAYER_ARN"
# Create an MCP tool function using the Bun runtime layer
# The function's code ZIP contains only handler.ts (not the Bun binary)
aws lambda create-function \
--function-name mcp-tool-bun-example \
--runtime provided.al2023 \
--architectures x86_64 \
--handler handler.main \
--role arn:aws:iam::123456789012:role/McpToolExecutionRole \
--code ZipFile=fileb://handler.zip \
--layers "$LAYER_ARN" \
--timeout 30 \
--memory-size 512 \
--region us-east-1
For Bun to handle the Lambda Runtime API loop automatically, use the @bun-community/lambda adapter in your handler. This adapter wraps your handler function with the GET-next / POST-response loop so your handler code is just a plain async function:
// handler.ts — your MCP tool handler, bundled into the function ZIP
// Bun resolves TypeScript natively; no tsc or esbuild step needed
import { LambdaContext, LambdaEvent } from "@bun-community/lambda";
// Export handler — @bun-community/lambda reads this export and runs the Runtime API loop
export async function main(event: LambdaEvent, context: LambdaContext) {
// MCP tool handlers receive HTTP events from Function URL or API Gateway
const body = event.body ? JSON.parse(event.body) : {};
// JSON-RPC 2.0 dispatch — forward to your MCP tool logic
const result = await dispatchMcpRequest(body);
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result),
};
}
async function dispatchMcpRequest(rpc: Record<string, unknown>) {
const method = rpc.method as string;
const params = rpc.params as Record<string, unknown>;
const tools: Record<string, (p: Record<string, unknown>) => Promise<unknown>> = {
"s3.listObjects": listS3Objects,
"s3.getObject": getS3Object,
};
if (!(method in tools)) {
return { jsonrpc: "2.0", id: rpc.id, error: { code: -32601, message: "Method not found" } };
}
const toolResult = await tools[method](params);
return { jsonrpc: "2.0", id: rpc.id, result: toolResult };
}
// Bun ships with a native S3 client — no @aws-sdk/client-s3 needed
async function listS3Objects(params: Record<string, unknown>) {
const bucket = params.bucket as string;
const prefix = (params.prefix as string) ?? "";
const s3 = new Bun.S3Client({ region: process.env.AWS_REGION });
const objects = await s3.list({ bucket, prefix });
return objects.contents?.map(o => ({ key: o.key, size: o.size })) ?? [];
}
async function getS3Object(params: Record<string, unknown>) {
const s3 = new Bun.S3Client({ region: process.env.AWS_REGION });
const file = s3.file(params.key as string, { bucket: params.bucket as string });
const content = await file.text();
return { content };
}
Deno custom runtime layer
Deno is a TypeScript-first runtime with built-in security permissions, a standard library, and no node_modules directory. For MCP tools that benefit from Deno's security model (explicit network and file system permission grants) or from its native TypeScript support without a build step, a Deno custom runtime layer follows the same pattern as the Bun layer.
#!/bin/bash
# Build a Deno custom runtime Lambda Layer
DENO_VERSION="2.1.4"
ARCH="x86_64-unknown-linux-gnu" # use "aarch64-unknown-linux-gnu" for arm64
LAYER_DIR="deno-layer"
mkdir -p "${LAYER_DIR}/bin"
# Deno distributes a single self-contained binary (~90 MB uncompressed)
# Factor this into the 250 MB unzipped layer size limit
curl -fsSL \
"https://github.com/denoland/deno/releases/download/v${DENO_VERSION}/deno-${ARCH}.zip" \
-o deno.zip
unzip -q deno.zip -d "${LAYER_DIR}/bin/"
chmod +x "${LAYER_DIR}/bin/deno"
# Cache Deno's TypeScript compiler at layer build time to avoid cold-start compilation
# Run a no-op TypeScript file to warm the cache into the layer
"${LAYER_DIR}/bin/deno" eval "console.log('cache warm')" 2>/dev/null || true
cat > "${LAYER_DIR}/bootstrap" << 'BOOTSTRAP'
#!/bin/sh
# Deno custom runtime bootstrap
# --allow-env: read Lambda environment variables (AWS credentials, region, etc.)
# --allow-net: make outbound HTTP calls (AWS SDK, downstream APIs)
# --allow-read=/var/task: read handler files from the function ZIP
# Add additional permissions as your tool requires
export PATH="/opt/bin:$PATH"
exec /opt/bin/deno run \
--allow-env \
--allow-net \
--allow-read=/var/task \
/var/task/handler.ts
BOOTSTRAP
chmod +x "${LAYER_DIR}/bootstrap"
cd "${LAYER_DIR}"
zip -r9 ../deno-runtime-layer.zip .
cd ..
echo "Deno layer ZIP size:"
ls -lh deno-runtime-layer.zip
# Deno binary is ~90 MB compressed — verify it fits within the 50 MB direct upload limit
# If it exceeds 50 MB, upload via S3:
# aws s3 cp deno-runtime-layer.zip s3://my-bucket/layers/deno-runtime-layer.zip
# aws lambda publish-layer-version --content S3Bucket=my-bucket,S3Key=layers/deno-runtime-layer.zip ...
Because Deno's binary is approximately 90 MB uncompressed, the layer ZIP itself is typically 45–55 MB. The direct upload limit for publish-layer-version is 50 MB compressed; if your Deno layer exceeds this, upload via S3 instead:
# Upload Deno layer via S3 to bypass the 50 MB direct upload limit
aws s3 cp deno-runtime-layer.zip \
s3://my-artifact-bucket/layers/deno-runtime-layer.zip \
--region us-east-1
aws lambda publish-layer-version \
--layer-name deno-runtime \
--description "Deno v2.1.4 custom runtime for Lambda" \
--content "S3Bucket=my-artifact-bucket,S3Key=layers/deno-runtime-layer.zip" \
--compatible-runtimes provided.al2023 \
--compatible-architectures x86_64 \
--region us-east-1
A Deno handler for an MCP tool looks like standard Deno TypeScript. Deno does not use node_modules — dependencies are imported via URLs or import maps. For production MCP tools, use an import map that pins all dependency versions to ensure reproducible deployments:
// handler.ts — Deno MCP tool handler
// Imports via URL with version pins (no node_modules, no package.json)
import { serve } from "https://deno.land/std@0.224.0/http/server.ts";
// Lambda Runtime API constants
const RUNTIME_API = `http://${Deno.env.get("AWS_LAMBDA_RUNTIME_API")}/2018-06-01/runtime`;
// Deno Runtime API loop — same contract as the shell bootstrap, implemented in TypeScript
async function runLambdaLoop() {
while (true) {
// Get next invocation
const nextRes = await fetch(`${RUNTIME_API}/invocation/next`);
const requestId = nextRes.headers.get("Lambda-Runtime-Aws-Request-Id")!;
const event = await nextRes.json();
try {
const result = await handleMcpRequest(event);
await fetch(`${RUNTIME_API}/invocation/${requestId}/response`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result),
});
} catch (err) {
await fetch(`${RUNTIME_API}/invocation/${requestId}/error`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
errorMessage: err instanceof Error ? err.message : String(err),
errorType: "HandlerError",
}),
});
}
}
}
async function handleMcpRequest(event: Record<string, unknown>) {
const body = typeof event.body === "string" ? JSON.parse(event.body) : event.body;
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { status: "ok" } }),
};
}
// Entry point
await runLambdaLoop();
Python version pinning
AWS Lambda's managed Python runtimes typically lag 6–12 months behind the latest CPython release. If your MCP tool requires Python 3.13 features (improved error messages, free-threaded mode, the new REPL) before AWS adds official support, you can package CPython 3.13 as a custom runtime layer.
Build CPython for Lambda's Amazon Linux 2023 environment using a Docker container that matches Lambda's base image:
# Dockerfile to build CPython 3.13 for Lambda (Amazon Linux 2023 base)
FROM public.ecr.aws/lambda/python:3.12 AS builder
# Install build dependencies
RUN dnf install -y \
gcc \
make \
openssl-devel \
bzip2-devel \
libffi-devel \
zlib-devel \
readline-devel \
sqlite-devel \
xz-devel \
ncurses-devel \
&& dnf clean all
# Download and build Python 3.13 from source
ARG PYTHON_VERSION=3.13.0
RUN curl -fsSL "https://www.python.org/ftp/python/${PYTHON_VERSION}/Python-${PYTHON_VERSION}.tgz" \
| tar -xz
RUN cd "Python-${PYTHON_VERSION}" && \
./configure \
--prefix=/opt/python313 \
--enable-optimizations \
--with-lto \
--enable-shared \
LDFLAGS="-Wl,-rpath,/opt/python313/lib" && \
make -j$(nproc) && \
make install
# Create the bootstrap script
RUN cat > /tmp/bootstrap << 'BOOTSTRAP'
#!/bin/sh
export PATH="/opt/python313/bin:$PATH"
export LD_LIBRARY_PATH="/opt/python313/lib:$LD_LIBRARY_PATH"
export PYTHONPATH="/var/task:${PYTHONPATH:-}"
exec /opt/python313/bin/python3.13 /var/task/handler.py
BOOTSTRAP
RUN chmod +x /tmp/bootstrap
# Package the layer: python313/ directory + bootstrap
FROM scratch AS layer-package
COPY --from=builder /opt/python313 /opt/python313
COPY --from=builder /tmp/bootstrap /opt/bootstrap
# Extract the layer content from the Docker build and zip it
docker build -t python313-layer-builder .
# Create a container and copy the layer content out
CONTAINER_ID=$(docker create python313-layer-builder)
mkdir -p python313-layer-content
docker cp "${CONTAINER_ID}:/opt/." python313-layer-content/
docker rm "$CONTAINER_ID"
# The bootstrap must be at the root of the layer (not in a subdirectory)
# and python313/ must be at /opt/python313 in the execution environment
cd python313-layer-content
zip -r9 ../python313-runtime-layer.zip .
cd ..
# Publish via S3 (CPython + stdlib is typically 60-80 MB compressed)
aws s3 cp python313-runtime-layer.zip \
s3://my-artifact-bucket/layers/python313-runtime-layer.zip
aws lambda publish-layer-version \
--layer-name python313-runtime \
--description "CPython 3.13.0 custom runtime" \
--content "S3Bucket=my-artifact-bucket,S3Key=layers/python313-runtime-layer.zip" \
--compatible-runtimes provided.al2023 \
--compatible-architectures x86_64 \
--region us-east-1
For most Python version-pinning scenarios, Lambda container images are a simpler alternative: build a container image FROM public.ecr.aws/lambda/python:3.13 (once AWS publishes it to ECR) or FROM python:3.13-slim with the Lambda Runtime Interface Client installed. Container images avoid the layer size constraints and do not require building CPython from source. Use the custom runtime layer approach when you specifically need the function ZIP deployment model (faster deploys, shared runtime across many functions) rather than per-function container images.
MCP server handler contract for custom runtimes
Regardless of whether the runtime is Bun, Deno, or a custom Python build, the handler file receives the same Lambda event structure. For MCP tools deployed behind a Function URL or API Gateway HTTP API, the event is a standard HTTP event with body, headers, requestContext, and path/query string fields.
A complete Bun/TypeScript MCP tool handler that implements the JSON-RPC 2.0 dispatch pattern:
// handler.ts — complete MCP tool handler for Bun runtime layer
// Build: bun build handler.ts --outfile handler.js --target bun
// Or deploy handler.ts directly — Bun parses TypeScript natively
import type { LambdaEvent, LambdaContext } from "@bun-community/lambda";
// MCP JSON-RPC 2.0 types
interface McpRequest {
jsonrpc: "2.0";
id: string | number;
method: string;
params?: Record<string, unknown>;
}
interface McpResponse {
jsonrpc: "2.0";
id: string | number;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
// Tool handler map: method name -> async handler function
const toolHandlers: Record<string, (params: Record<string, unknown>) => Promise<unknown>> = {
"tools/list": async () => ({
tools: [
{ name: "readFile", description: "Read a file from S3", inputSchema: { type: "object", properties: { bucket: { type: "string" }, key: { type: "string" } }, required: ["bucket", "key"] } },
{ name: "writeFile", description: "Write a file to S3", inputSchema: { type: "object", properties: { bucket: { type: "string" }, key: { type: "string" }, content: { type: "string" } }, required: ["bucket", "key", "content"] } },
],
}),
"tools/call": async (params) => {
const toolName = params.name as string;
const toolInput = (params.arguments ?? {}) as Record<string, unknown>;
const impl = specificTools[toolName];
if (!impl) throw new Error(`Unknown tool: ${toolName}`);
return impl(toolInput);
},
};
// Specific tool implementations
const specificTools: Record<string, (args: Record<string, unknown>) => Promise<unknown>> = {
async readFile({ bucket, key }) {
const s3 = new Bun.S3Client({ region: process.env.AWS_REGION });
const file = s3.file(key as string, { bucket: bucket as string });
const content = await file.text();
return { content: [{ type: "text", text: content }] };
},
async writeFile({ bucket, key, content }) {
const s3 = new Bun.S3Client({ region: process.env.AWS_REGION });
await s3.write(key as string, content as string, { bucket: bucket as string });
return { content: [{ type: "text", text: `Written to s3://${bucket}/${key}` }] };
},
};
// Main Lambda handler — @bun-community/lambda calls this for each invocation
export async function main(event: LambdaEvent, _context: LambdaContext) {
// Parse the MCP request from the HTTP body
let mcpRequest: McpRequest;
try {
const rawBody = event.isBase64Encoded
? Buffer.from(event.body ?? "", "base64").toString("utf-8")
: (event.body ?? "{}");
mcpRequest = JSON.parse(rawBody);
} catch {
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }),
};
}
// Dispatch to the tool handler
let mcpResponse: McpResponse;
const handler = toolHandlers[mcpRequest.method];
if (!handler) {
mcpResponse = { jsonrpc: "2.0", id: mcpRequest.id, error: { code: -32601, message: "Method not found" } };
} else {
try {
const result = await handler(mcpRequest.params ?? {});
mcpResponse = { jsonrpc: "2.0", id: mcpRequest.id, result };
} catch (err) {
mcpResponse = {
jsonrpc: "2.0",
id: mcpRequest.id,
error: { code: -32000, message: err instanceof Error ? err.message : String(err) },
};
}
}
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(mcpResponse),
};
}
The handler ZIP for deployment contains only handler.ts (and any local modules it imports). The Bun binary and the bootstrap script live in the runtime layer — the function ZIP stays small (typically under 1 MB for a TypeScript MCP tool with no node_modules).
# Package only the handler file into the function ZIP
zip handler.zip handler.ts
# Update an existing function with the new handler code
aws lambda update-function-code \
--function-name mcp-tool-bun-example \
--zip-file fileb://handler.zip \
--region us-east-1
Cold start comparison
Cold start times depend on function memory, layer size, and how much initialization the runtime performs before processing the first invocation. The values below are approximate for a minimal MCP tool with no database connections:
| Runtime | Type | Approx. cold start | Layer size (unzipped) | Notes |
|---|---|---|---|---|
| Node.js 22 | Native managed | 200–400ms | No separate layer needed | V8 JIT; good for most MCP tools; largest ecosystem |
| Bun 1.1.x (custom runtime layer) | Custom runtime | 80–200ms | ~40 MB (x86_64 binary) | JavaScriptCore; faster cold start; native TS; Bun stdlib replaces many npm packages |
| Deno 2.x (custom runtime layer) | Custom runtime | 150–350ms | ~90 MB (single binary) | V8; similar to Node.js cold start; strong security model; no node_modules |
| Python 3.12 | Native managed | 100–250ms | No separate layer needed | Fast cold start; large stdlib; good for data-processing MCP tools |
| Python 3.13 (custom runtime layer) | Custom runtime | 200–450ms | ~70 MB (CPython + stdlib) | Slightly slower than managed Python 3.12 due to binary loading overhead; use for specific 3.13 features |
The custom runtime cold start overhead (compared to the equivalent managed runtime) comes from Lambda loading the runtime layer binary off of storage before executing bootstrap. This overhead scales with binary size: Bun's 40 MB binary loads faster than Deno's 90 MB binary. With 1 GB function memory, Lambda allocates proportionally more I/O bandwidth for layer loading, which reduces the cold start overhead significantly.
Building for the right architecture
Lambda supports two CPU architectures: x86_64 (Intel/AMD) and arm64 (AWS Graviton3). Graviton3 functions typically cost 20% less and provide similar or better performance for CPU-bound workloads. Bun and Deno publish separate binaries for each architecture — you must build architecture-specific layers and publish them separately.
#!/bin/bash
# Build and publish Bun runtime layers for both architectures
BUN_VERSION="1.1.34"
for ARCH_PAIR in "linux-x64:x86_64" "linux-aarch64:arm64"; do
BUN_ARCH="${ARCH_PAIR%%:*}"
LAMBDA_ARCH="${ARCH_PAIR##*:}"
LAYER_DIR="bun-layer-${LAMBDA_ARCH}"
echo "Building Bun runtime layer for ${LAMBDA_ARCH}..."
mkdir -p "${LAYER_DIR}/bin"
curl -fsSL \
"https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-${BUN_ARCH}.zip" \
-o "bun-${LAMBDA_ARCH}.zip"
unzip -q "bun-${LAMBDA_ARCH}.zip" -d "${LAYER_DIR}/bin_tmp/"
mv "${LAYER_DIR}/bin_tmp/bun-${BUN_ARCH}/bun" "${LAYER_DIR}/bin/bun"
rm -rf "${LAYER_DIR}/bin_tmp"
chmod +x "${LAYER_DIR}/bin/bun"
cat > "${LAYER_DIR}/bootstrap" << 'BOOTSTRAP'
#!/bin/sh
export PATH="/opt/bin:$PATH"
exec /opt/bin/bun /var/task/handler.ts
BOOTSTRAP
chmod +x "${LAYER_DIR}/bootstrap"
cd "${LAYER_DIR}"
zip -r9 "../bun-runtime-layer-${LAMBDA_ARCH}.zip" .
cd ..
aws lambda publish-layer-version \
--layer-name "bun-runtime-${LAMBDA_ARCH}" \
--description "Bun v${BUN_VERSION} custom runtime — ${LAMBDA_ARCH}" \
--zip-file "fileb://bun-runtime-layer-${LAMBDA_ARCH}.zip" \
--compatible-runtimes provided.al2023 \
--compatible-architectures "${LAMBDA_ARCH}" \
--region us-east-1
echo "Published bun-runtime-${LAMBDA_ARCH} to us-east-1"
done
When creating or updating a function, specify the architecture to match the layer:
# Create an arm64 (Graviton3) function using the arm64 Bun layer
aws lambda create-function \
--function-name mcp-tool-bun-arm64 \
--runtime provided.al2023 \
--architectures arm64 \
--handler handler.main \
--role arn:aws:iam::123456789012:role/McpToolExecutionRole \
--code ZipFile=fileb://handler.zip \
--layers "arn:aws:lambda:us-east-1:123456789012:layer:bun-runtime-arm64:1" \
--timeout 30 \
--memory-size 512 \
--region us-east-1
Test the layer locally before deploying by running a Docker container that matches Lambda's execution environment. Use --platform linux/arm64 to emulate Graviton on a non-ARM development machine:
# Test the bootstrap script locally using the Lambda provided.al2023 base image
# For x86_64:
docker run --rm \
--platform linux/amd64 \
-v "$(pwd)/bun-layer-x86_64:/opt" \
-v "$(pwd)/handler.ts:/var/task/handler.ts" \
-e AWS_LAMBDA_RUNTIME_API=localhost:9001 \
public.ecr.aws/lambda/provided:al2023 \
/opt/bootstrap
# For arm64 (Graviton) — requires QEMU or an arm64 host:
docker run --rm \
--platform linux/arm64 \
-v "$(pwd)/bun-layer-arm64:/opt" \
-v "$(pwd)/handler.ts:/var/task/handler.ts" \
-e AWS_LAMBDA_RUNTIME_API=localhost:9001 \
public.ecr.aws/lambda/provided:al2023-arm64 \
/opt/bootstrap
The local test will fail because no Lambda Runtime API server is running at localhost:9001, but it will confirm that the bootstrap executable starts successfully, the Bun binary loads, and the handler TypeScript is parseable. Any architecture mismatch (x86_64 binary on arm64) surfaces immediately as Exec format error before you deploy to Lambda.
AliveMCP and custom runtime cold starts
Custom runtime cold starts can run 200–500ms longer than equivalent managed runtimes because Lambda must load the runtime binary from the layer before executing bootstrap. The overhead is proportional to binary size: a 40 MB Bun binary adds less latency than a 90 MB Deno binary. This matters for MCP tools where latency directly affects agent loop throughput — an 800ms cold start on a Bun function configured with a 1s timeout will cause sporadic timeouts when the execution environment is recycled after a period of inactivity.
AliveMCP's 60-second probe interval surfaces this bimodal pattern quickly. AliveMCP's Author tier response-time history plots every probe's response time over the last 24 hours — warm invocations cluster around 50–100ms while cold starts cluster at 400–900ms. This visualization makes it immediately obvious whether your MCP tool's timeout setting has enough headroom for cold-start scenarios and whether cold starts are happening frequently enough to warrant enabling Provisioned Concurrency.
For MCP tools where latency is critical — tools that sit in a tight agent loop where every round-trip adds to user-perceived latency — configure a small Provisioned Concurrency value (2–5 pre-warmed instances). Provisioned Concurrency eliminates cold starts entirely by keeping execution environments pre-initialized. For Bun custom runtimes, the cost is modest: a 512 MB function with 2 provisioned instances costs approximately $2–4/month in us-east-1, and AliveMCP's probe traffic counts as free invocations under Lambda's free tier for provisioned capacity. The AliveMCP dashboard shows whether warm invocation latency is stable — if you start seeing p99 response times creep up even on warm invocations, that is a signal that the Bun handler itself needs optimization, not more concurrency.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
Runtime.ExitError: RequestId: ... Error: Runtime exited with error: exit status 1 | bootstrap script fails to execute; most common causes are: wrong path to the runtime binary, binary not marked executable, or binary built for the wrong CPU architecture (x86_64 binary on an arm64 function) | Verify bootstrap is executable with unzip -l layer.zip | grep bootstrap and check the Unix permissions; confirm the architecture matches by running file /path/to/bun inside the layer directory; test locally with docker run --platform linux/amd64 public.ecr.aws/lambda/provided:al2023 /opt/bootstrap |
Task timed out after X seconds on cold starts only; warm invocations succeed | The runtime binary initialization (Bun JIT warmup, Deno V8 startup, CPython import phase) exceeds the function timeout; Deno and large Python packages are the most common culprits | Increase the function timeout to give cold starts headroom (minimum 3s for Bun, 5s for Deno, 3s for CPython 3.13); add Provisioned Concurrency to eliminate cold starts on latency-sensitive MCP tools; monitor cold-start frequency with AliveMCP response-time history |
Runtime.InvalidEntrypoint — Lambda cannot find bootstrap | bootstrap is not at the root of the layer ZIP; it was accidentally placed in a subdirectory (e.g., bin/bootstrap) instead of the layer root, which maps to /opt/bootstrap | Verify with unzip -l layer.zip | grep bootstrap — the path should be bootstrap not bin/bootstrap or any other prefix; re-zip from the layer directory with cd layer-dir && zip -r9 ../layer.zip . |
Handler file not found at /var/task/handler.ts | The function's deployment ZIP does not contain the handler file; the runtime layer provides the interpreter (Bun, Deno) but the function ZIP must separately contain the handler — they are two different ZIPs deployed separately | Verify the function deployment ZIP contains the handler file with unzip -l handler.zip; redeploy the function code with aws lambda update-function-code --zip-file fileb://handler.zip; the layer ZIP and the function ZIP are never merged |
Deno handler fails with PermissionDenied: Requires net access | The bootstrap script does not include --allow-net in the deno run invocation; Deno requires explicit permission grants for all I/O operations and network access | Add --allow-net to the deno run command in the bootstrap script; also add --allow-env for environment variable access (AWS credentials, region) and --allow-read=/var/task for reading the handler file; for tighter security, scope --allow-net to specific domains your MCP tool calls |