Guide · AWS Lambda
MCP Server Lambda Response Streaming — SSE transport, chunked JSON, streamifyResponse
Lambda Response Streaming lets your MCP server send partial results to the client before the handler finishes executing. The default Lambda invocation model buffers the entire response body in memory and sends it atomically after the handler returns. This is fine for JSON-RPC request/response tool calls, but breaks down for two MCP patterns: SSE (Server-Sent Events) transport, where the server must push events to the client over an open connection, and long-running tool calls that generate results progressively (database query with paginated results, multi-step orchestration, LLM streaming output). With Response Streaming, you write chunks to the response stream as they are ready — clients see data immediately, and the 6 MB buffered-response size limit no longer applies.
TL;DR
Wrap your handler with awslambda.streamifyResponse (Node.js runtime built-in), attach a Function URL with InvokeMode: RESPONSE_STREAM, write response headers before the first body byte, and flush chunks to the stream. The streaming payload limit is 20 MB per invocation. AliveMCP probes tools/list on streaming endpoints the same way — AliveMCP reads until the stream closes and validates the full payload, so a streaming transport does not break uptime monitoring.
BUFFERED vs RESPONSE_STREAM invoke modes
Lambda Function URLs support two invoke modes. The mode is set at the Function URL resource, not per-request.
| Property | BUFFERED (default) | RESPONSE_STREAM |
|---|---|---|
| Response delivery | Entire body buffered; sent after handler returns | Chunks sent as they are written to the stream |
| Max response payload | 6 MB | 20 MB (hard limit per invocation) |
| Content-Type header | Set in return value headers object | Written to stream before first body byte via responseStream.setContentType() |
| SSE transport support | No — client must wait for full response | Yes — client receives events as chunks arrive |
| CloudWatch extra metrics | None | ResponseStreamingDataLength, ResponseStreamingThroughput |
| SDK invoke method | InvokeCommand | InvokeWithResponseStreamCommand |
You cannot switch invoke modes on an existing Function URL without recreating it. In CDK, switching invokeMode triggers a URL replacement (the URL hostname changes), so update any DNS records or MCP client configurations that hardcode the Function URL hostname.
Implementing streamifyResponse for MCP SSE transport
The awslambda global is available in Node.js Lambda runtimes without any package install. The streamifyResponse wrapper transforms your handler signature to receive a writable responseStream argument instead of returning a value.
// mcp-handler-streaming.ts
// Runtime: nodejs22.x — awslambda is a global, no import needed
import { APIGatewayProxyEventV2 } from "aws-lambda";
const handler = awslambda.streamifyResponse(
async (
event: APIGatewayProxyEventV2,
responseStream: AWSLambda.ResponseStream,
context: AWSLambda.Context
) => {
// Headers must be written before any body data
const metadata = {
statusCode: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", // prevent nginx/Caddy from buffering SSE
},
};
responseStream = awslambda.HttpResponseStream.from(responseStream, metadata);
// Write SSE events as chunks
const writeEvent = (event: string, data: unknown) => {
responseStream.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
try {
// MCP initialize response
writeEvent("message", {
jsonrpc: "2.0",
id: 1,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "alivemcp-probe", version: "1.0.0" },
},
});
// Stream tool call result progressively
const toolResults = await runToolCallInChunks(event);
for await (const chunk of toolResults) {
writeEvent("message", {
jsonrpc: "2.0",
id: chunk.id,
result: { content: [{ type: "text", text: chunk.text }] },
});
}
} finally {
responseStream.end();
}
}
);
export { handler };
Critical ordering: responseStream.write() called before awslambda.HttpResponseStream.from() will silently drop data — always attach metadata (headers/status) to the stream before writing body bytes. The finally { responseStream.end() } is mandatory; forgetting it causes the client connection to hang until the Lambda function timeout.
CDK: Function URL with RESPONSE_STREAM invoke mode
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 mcpStreamingFn = new lambdaNodeJs.NodejsFunction(this, "McpStreamingFn", {
entry: "src/mcp-handler-streaming.ts",
runtime: lambda.Runtime.NODEJS_22_X,
timeout: Duration.minutes(15), // streaming connections can be long-lived
memorySize: 512,
bundling: {
// awslambda global is not in @types/aws-lambda — add type stubs
externalModules: [],
},
});
const streamUrl = mcpStreamingFn.addFunctionUrl({
authType: lambda.FunctionUrlAuthType.NONE,
invokeMode: lambda.InvokeMode.RESPONSE_STREAM, // enables streaming
cors: {
allowedOrigins: ["*"],
allowedMethods: [lambda.HttpMethod.POST, lambda.HttpMethod.GET],
allowedHeaders: ["Content-Type", "Authorization"],
},
});
The 15-minute timeout is relevant for SSE connections — an MCP client holding an open SSE channel will keep the Lambda instance alive for as long as the handler is running. AliveMCP's probe uses a 30-second timeout on tools/list calls regardless of invoke mode, so a streaming endpoint that stalls without writing any data will trigger an AliveMCP alert after 30 seconds.
Payload size and throughput limits
| Limit | Value | What happens when exceeded |
|---|---|---|
| Max streaming response payload | 20 MB per invocation | Lambda forcibly closes the stream mid-response; client receives a truncated body |
| Max chunk write size | No documented per-chunk limit; write chunks ≤ 64 KB for best throughput | Large single writes are buffered internally before transmission |
| Function timeout | 15 minutes max | Lambda terminates the handler; stream closed mid-response |
| Concurrent streaming invocations | Same Lambda concurrency limit as buffered (default 1000/region) | 429 throttle; Function URL returns HTTP 429 to client |
For MCP tool results that could exceed 20 MB (large file reads, database dumps), split the tool into paginated calls and have the MCP client invoke multiple times with a cursor parameter rather than streaming a single oversized response.
Monitoring streaming endpoints with CloudWatch
# Average bytes streamed per invocation (useful for baseline baselining)
aws cloudwatch get-metric-statistics \
--namespace AWS/Lambda \
--metric-name ResponseStreamingDataLength \
--dimensions Name=FunctionName,Value=McpStreamingFn \
--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 Average
# Throughput in bytes/second — alert if drops below baseline
aws cloudwatch get-metric-statistics \
--namespace AWS/Lambda \
--metric-name ResponseStreamingThroughput \
--dimensions Name=FunctionName,Value=McpStreamingFn \
--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 Average
AliveMCP tracks total response time (time-to-first-byte + time-to-stream-complete) and flags streaming endpoints where time-to-first-byte exceeds 5 seconds — this usually means the handler is doing synchronous work before calling responseStream.write() for the first time, negating the value of streaming.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Client receives empty response body | responseStream.end() called before any write, or handler throws before writing | Wrap handler body in try/finally; write at least one byte before end() |
| Headers not received by client; body arrives as raw bytes | HttpResponseStream.from() not called before writes | Always attach metadata before first write; order: from() → write() → end() |
| SSE client sees events batch-delivered, not streamed | Reverse proxy (ALB, CloudFront, Caddy) is buffering the response | Set X-Accel-Buffering: no response header; for CloudFront, disable compress on the behavior |
| Stream truncated mid-response | 20 MB payload limit reached | Paginate large results; use cursor pattern |
| AliveMCP reports timeout on streaming endpoint | Handler writes no data for > 30 seconds (AliveMCP probe timeout) | Write an SSE keepalive comment (: keepalive\n\n) every 15 seconds during long processing |