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.

PropertyBUFFERED (default)RESPONSE_STREAM
Response deliveryEntire body buffered; sent after handler returnsChunks sent as they are written to the stream
Max response payload6 MB20 MB (hard limit per invocation)
Content-Type headerSet in return value headers objectWritten to stream before first body byte via responseStream.setContentType()
SSE transport supportNo — client must wait for full responseYes — client receives events as chunks arrive
CloudWatch extra metricsNoneResponseStreamingDataLength, ResponseStreamingThroughput
SDK invoke methodInvokeCommandInvokeWithResponseStreamCommand

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

LimitValueWhat happens when exceeded
Max streaming response payload20 MB per invocationLambda forcibly closes the stream mid-response; client receives a truncated body
Max chunk write sizeNo documented per-chunk limit; write chunks ≤ 64 KB for best throughputLarge single writes are buffered internally before transmission
Function timeout15 minutes maxLambda terminates the handler; stream closed mid-response
Concurrent streaming invocationsSame 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

SymptomCauseFix
Client receives empty response bodyresponseStream.end() called before any write, or handler throws before writingWrap handler body in try/finally; write at least one byte before end()
Headers not received by client; body arrives as raw bytesHttpResponseStream.from() not called before writesAlways attach metadata before first write; order: from() → write() → end()
SSE client sees events batch-delivered, not streamedReverse proxy (ALB, CloudFront, Caddy) is buffering the responseSet X-Accel-Buffering: no response header; for CloudFront, disable compress on the behavior
Stream truncated mid-response20 MB payload limit reachedPaginate large results; use cursor pattern
AliveMCP reports timeout on streaming endpointHandler writes no data for > 30 seconds (AliveMCP probe timeout)Write an SSE keepalive comment (: keepalive\n\n) every 15 seconds during long processing