AWS Lambda · 2026-10-24 · Lambda Advanced arc

Lambda Advanced Patterns for MCP Servers: Response Streaming, Extensions, Destinations, and ESM Filters

Lambda's default request-response model covers the majority of MCP server use cases, but four advanced features each unlock a category of workloads that the default model handles poorly — and each has a non-obvious prerequisite that burns teams at deployment time. Lambda Response Streaming is required for SSE-transport MCP servers — the default BUFFERED invoke mode holds the entire response body in memory until the handler returns, which means the MCP client sees nothing until processing is complete, breaking the SSE protocol's push-event semantics entirely; switching to RESPONSE_STREAM mode via Function URL and wrapping the handler in awslambda.streamifyResponse enables per-chunk delivery, but the order of operations matters: calling responseStream.write() before awslambda.HttpResponseStream.from() silently drops all data, and forgetting responseStream.end() in a finally block causes the client connection to hang until the Lambda timeout. Lambda Extensions let an MCP server pre-load secrets from Secrets Manager and SSM Parameter Store before the first handler invocation — the AWS Parameters and Secrets Lambda Extension starts a local HTTP server on port 2773 that caches credential values and serves them to the handler without an SDK call or network round-trip to the regional endpoint, adding 150–300 ms to cold start once in exchange for zero-cost reads on every warm invocation — but the extension must register with the Extension API as its very first operation; any initialization work before registration risks exceeding the 10-second Init budget and being killed. Lambda Destinations route the result of an async invocation to a downstream queue or bus after the function completes, providing both the original event and the function's response (or error details) in a rich envelope — something a bare DLQ cannot do, because a DLQ only captures the original event payload; for AliveMCP's probe worker architecture, the pattern of EventBridge schedule → probe Lambda → OnSuccess SQS → result consumer keeps the probe function stateless while giving the consumer full invocation context for debugging, and setting retryAttempts: 0 prevents duplicate health-check pings that could trigger false-positive alerts. Lambda EventSourceMapping filters let teams declare which SQS messages, Kinesis records, or DynamoDB Streams events should trigger the function — records that do not match are discarded at the Lambda service layer without invoking the handler, with no billing for filtered-out records; but the OR-versus-AND semantics of multi-pattern filter criteria are routinely confused (multiple Filters entries use OR semantics; fields within a single filter pattern use AND), and DynamoDB Streams filters on NewImage require the stream to use NEW_IMAGE or NEW_AND_OLD_IMAGES view type — a KEYS_ONLY stream has no image data for the filter to match. This guide synthesises all four patterns. Pattern 1 covers Response Streaming — invoke mode selection, streamifyResponse handler structure, CDK configuration, payload limits, and CloudWatch metrics — synthesised from Lambda Response Streaming for MCP Servers — SSE transport, chunked JSON, streamifyResponse. Pattern 2 covers Lambda Extensions — internal versus external extension lifecycle, the Parameters and Secrets Extension cache API, Telemetry API subscription, and cold start overhead — synthesised from Lambda Extensions for MCP Servers — pre-load secrets, telemetry API, Parameters extension. Pattern 3 covers Lambda Destinations — async invocation scope, target types, payload envelope structure, DLQ versus Destinations comparison, and CDK configuration via configureAsyncInvoke — synthesised from Lambda Destinations for MCP Servers — async invocation result routing, OnSuccess, OnFailure. Pattern 4 covers ESM FilterCriteria — filter syntax, SQS / Kinesis / DynamoDB Streams filter paths, OR and AND semantics, cost impact, and CDK configuration — synthesised from Lambda ESM Filters for MCP Servers — FilterCriteria, SQS, Kinesis, DynamoDB Streams.

TL;DR

Pattern 1 — Lambda Response Streaming: SSE Transport and Chunked Tool Results

Why the default BUFFERED mode breaks SSE transport

Lambda's default invocation model buffers the entire response body in memory and delivers it atomically after the handler function returns. For standard JSON-RPC MCP tool calls — where the client sends a request and waits for a complete response — this works perfectly. The problem arises with two MCP transport patterns that require the server to send data before it has finished processing.

The first is SSE (Server-Sent Events) transport, where the MCP protocol layer opens a persistent connection and the server pushes events to the client as they occur. In BUFFERED mode, the Lambda service holds every write to the response stream until the handler returns — the client receives nothing until the connection closes, which defeats the purpose of SSE entirely.

The second is progressive tool result streaming — a tool call that performs a large database query, a multi-step orchestration, or wraps an LLM streaming response benefits from sending partial results as they become available rather than buffering the entire payload and sending it at once. In BUFFERED mode, the 6 MB response size limit also applies; in RESPONSE_STREAM mode this becomes 20 MB.

BUFFERED vs RESPONSE_STREAM invoke modes

The invoke mode is set at the Function URL resource, not per-request, and cannot be changed without recreating the URL (which changes the hostname).

PropertyBUFFERED (default)RESPONSE_STREAM
Response deliveryEntire body sent after handler returnsChunks sent as written to the stream
Max response payload6 MB20 MB per invocation
SSE transport supportNoYes
Header attachmentIn return value headers objectVia responseStream.setContentType() before first write
CloudWatch extra metricsNoneResponseStreamingDataLength, ResponseStreamingThroughput

streamifyResponse handler structure

The awslambda global is available in Node.js Lambda runtimes without any npm package. The streamifyResponse wrapper changes the handler signature to receive a writable responseStream argument instead of returning a value.

// awslambda is a Node.js Lambda runtime global — no import needed
const handler = awslambda.streamifyResponse(
  async (event, responseStream, context) => {
    // STEP 1: Attach headers BEFORE any write
    // Writing before from() silently drops data
    const metadata = {
      statusCode: 200,
      headers: {
        "Content-Type": "text/event-stream",
        "Cache-Control": "no-cache",
        "X-Accel-Buffering": "no",  // prevent Caddy / nginx from buffering SSE
      },
    };
    responseStream = awslambda.HttpResponseStream.from(responseStream, metadata);

    const writeEvent = (name, data) => {
      responseStream.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
    };

    try {
      // MCP initialize response — first event
      writeEvent("message", {
        jsonrpc: "2.0", id: 1,
        result: {
          protocolVersion: "2024-11-05",
          capabilities: { tools: {} },
          serverInfo: { name: "my-mcp-server", version: "1.0.0" },
        },
      });

      // Progressive tool result
      for await (const chunk of runToolCallInChunks(event)) {
        writeEvent("message", {
          jsonrpc: "2.0", id: chunk.id,
          result: { content: [{ type: "text", text: chunk.text }] },
        });
        // Write keepalive every 15s during long processing
        // — prevents AliveMCP 30s probe timeout from firing
      }
    } finally {
      // MANDATORY — forgetting this hangs client until Lambda timeout
      responseStream.end();
    }
  }
);

export { handler };

Three ordering invariants matter: (1) awslambda.HttpResponseStream.from() must be called before any write() — any write before from() is silently discarded; (2) from() takes the current responseStream reference as input and returns a new decorated stream that must be used for all subsequent writes; (3) responseStream.end() is mandatory in a finally block — if the handler throws without calling end(), the client connection hangs open until the Lambda invocation timeout (up to 15 minutes).

AliveMCP and streaming endpoint monitoring

AliveMCP probes tools/list on streaming endpoints identically to buffered endpoints — it reads the response stream until it closes and validates the complete JSON-RPC payload. The probe uses a 30-second timeout; if a streaming handler begins long processing before writing any data, it will trigger an AliveMCP alert even if the endpoint is functioning correctly. The fix is a periodic SSE keepalive comment (: keepalive\n\n) emitted every 15 seconds during any processing that does not produce output — the comment satisfies the probe's liveness check without disrupting the MCP client, which ignores SSE comment lines.

AliveMCP also separately tracks time-to-first-byte for streaming endpoints and flags functions where TTFB exceeds 5 seconds — this almost always means the handler is performing synchronous work (database queries, credential fetches) before its first write() call, negating the value of streaming. The fix is to write the MCP initialize response event immediately, then begin the expensive work.

Payload limits and the cursor pattern

The 20 MB streaming payload limit is enforced per invocation. Lambda forcibly closes the stream when this limit is reached — the client receives a truncated body with no indication that the response was cut short. For MCP tools that could return large datasets (registry scans, file reads, database exports), the correct pattern is to expose a cursor parameter in the tool schema and return paginated results across multiple synchronous tool calls rather than streaming a single large response. The MCP client calls the tool once, receives a result with a cursor, calls again with the cursor, and continues until the tool returns no cursor.

Pattern 2 — Lambda Extensions: Credential Pre-loading and Telemetry Forwarding

The credential-per-invocation problem

Without extensions, an MCP server Lambda function that reads secrets from Secrets Manager or SSM Parameter Store must either call the AWS SDK in the handler on every invocation (adding 50–200 ms of latency plus the API cost), or cache the value in a module-level variable and accept that the cached value will eventually become stale. Extensions solve this differently: an external extension starts a local HTTP server before the first handler invocation and caches the secret value internally, refreshing it on a configurable TTL. The handler reads from localhost:2773 — no SDK initialization, no network round-trip to the regional endpoint, and the cached value is available even during cold starts after the extension's Init phase completes.

Internal vs external extensions

TypeHow it runsInit timingShutdown signal
InternalSame process as the function handler (e.g., via NODE_OPTIONS=--require /opt/extension-init.js)Runs before handler module is loadedSame SIGTERM as the function process
ExternalSeparate process started by Lambda before the function runtimeMust register with Extension API within 10-second Init budgetSIGTERM + SHUTDOWN event; 2 seconds to clean up

The AWS Parameters and Secrets Lambda Extension is an external extension distributed as a Lambda layer. It starts an HTTP server on port 2773 that proxies credential reads to Secrets Manager and SSM while maintaining an in-memory cache. The critical Init constraint: external extensions must call POST /2020-01-01/extension/register on the Lambda Extension API as their very first operation. Any initialization work — network calls, file reads, local server startup — performed before registration risks timing out the 10-second Init budget, causing Lambda to kill the extension process and fail the function invocation.

AWS Parameters and Secrets Lambda Extension

Add the layer ARN for the extension. The ARN is region-specific and architecture-specific (arm64 and x86_64 have separate ARNs).

// mcp-handler.ts — read secrets via extension's local HTTP cache
const getSecret = async (secretId: string) => {
  const res = await fetch(
    `http://localhost:2773/secretsmanager/get?secretId=${secretId}`,
    // X-Aws-Parameters-Secrets-Token must equal AWS_SESSION_TOKEN exactly
    { headers: { "X-Aws-Parameters-Secrets-Token": process.env.AWS_SESSION_TOKEN! } }
  );
  if (!res.ok) throw new Error(`Extension HTTP ${res.status}`);
  const body = await res.json();
  return JSON.parse(body.SecretString);
};

// Cache at module level — extension handles TTL refresh externally
let dbCredentials: { host: string; password: string } | null = null;

export const handler = async (event: unknown) => {
  if (!dbCredentials) {
    dbCredentials = await getSecret("alivemcp/prod/db-credentials");
  }
  // handler uses dbCredentials — zero SDK calls during warm invocations
};

The extension uses the Lambda execution environment's IAM credentials automatically — grant secretsmanager:GetSecretValue (and kms:Decrypt if the secret uses a CMK) to the function's execution role.

Cache TTL is configurable via environment variables. The default 300-second TTL is appropriate for most static credentials. For RDS IAM authentication tokens — which expire 15 minutes after generation — set SECRETS_MANAGER_TTL=600 to ensure the cached token is never served past its natural expiry. The extension refreshes values on TTL expiry before serving the next request, so the handler sees a fresh token without any explicit refresh logic.

Environment variableDefaultUse case
SECRETS_MANAGER_TTL300 sLower for rotating credentials; raise for stable static secrets
SSM_PARAMETER_STORE_TTL300 sSame; SSM SecureString values cached separately from Secrets Manager
PARAMETERS_SECRETS_EXTENSION_CACHE_SIZE1000Max secret values in memory; reduce for memory-constrained functions

Lambda Telemetry API

The Telemetry API is a separate extension capability — distinct from the Parameters and Secrets Extension — that lets an external extension subscribe to three event streams and receive them as HTTP POST batches on a local port. This replaces the need to patch the function handler to forward logs to a custom endpoint.

An extension subscribes by calling PUT /2022-07-01/telemetry on the Extension API after registering. The subscription specifies which event types to receive (function for handler logs, platform for Lambda lifecycle events, extension for extension logs), the buffering parameters, and a local HTTP destination. Lambda batches events to the extension's server according to maxBytes, maxItems, and timeoutMs thresholds — whichever fires first flushes the buffer.

// Subscribe to Telemetry API after registering as an extension
await fetch(`http://${RUNTIME_API}/2022-07-01/telemetry`, {
  method: "PUT",
  headers: {
    "Content-Type": "application/json",
    "Lambda-Extension-Identifier": extensionId,
  },
  body: JSON.stringify({
    schemaVersion: "2022-12-13",
    types: ["function", "platform"],  // omit "extension" to reduce noise
    buffering: {
      maxBytes: 262144,   // flush at 256 KB
      maxItems: 1000,     // flush at 1000 events
      timeoutMs: 25,      // flush every 25ms for low-latency alerting
    },
    destination: {
      protocol: "HTTP",
      URI: "http://sandbox.localdomain:4243",
    },
  }),
});

One important constraint: Lambda may not deliver all buffered telemetry events when a function times out. The extension receives a SHUTDOWN event and has 2 seconds to drain any remaining buffer before the execution environment is frozen. Design telemetry forwarding to accept partial event loss on function timeouts rather than treating every log line as guaranteed-delivered.

Cold start overhead and Provisioned Concurrency interaction

Each external extension adds to cold start duration because Lambda waits for all registered extensions to complete their Init phase before the handler runs. The AWS Parameters and Secrets Extension adds 150–300 ms per cold start (local HTTP server initialization + cache pre-warming). Multiple extensions add linearly.

If cold start latency is critical for an MCP server that must respond quickly to the first probe after a quiet period, combining extensions with Provisioned Concurrency eliminates the problem: provisioned concurrency pre-initializes the execution environment including all extensions, so extension Init cost is paid once at provisioning time rather than on the first invocation after a cold period.

Pattern 3 — Lambda Destinations: Async Invocation Result Routing

The async invocation observability gap

Synchronous Lambda invocations (API Gateway, Function URLs with direct request/response, InvocationType: RequestResponse) return the function's response directly to the caller. The caller knows immediately whether the function succeeded or failed.

Async invocations work differently: the caller sends the event and immediately receives an HTTP 202. Lambda queues the invocation, retries on failure up to two times by default, and then — without Destinations — discards the result silently. The only way to know a function failed is to search CloudWatch Logs, which requires knowing the function name, the log group, the approximate time window, and the ability to distinguish errors from expected logs. A DLQ improves on this by capturing the original event after all retries are exhausted, but a DLQ still only captures the input — not the function's error output or how many times it was retried.

Lambda Destinations solve the observability gap: they capture the function's full result context — input event, output (or error), invocation count, function ARN, and condition — and route it to a downstream target after the function finishes.

When Destinations apply

Destinations only activate on asynchronous invocations. A function is invoked asynchronously when triggered by SNS, S3 event notifications, EventBridge rules, IoT rules, or when the caller explicitly uses InvocationType: Event. Destinations do not apply to synchronous invocations — API Gateway, Function URLs with BUFFERED or RESPONSE_STREAM mode, or InvocationType: RequestResponse.

Payload envelope structure

The Destinations payload wraps the function's input and output in a structured envelope. This is what arrives at the SQS queue or EventBridge event bus:

// OnSuccess envelope — received at the destination target
{
  "version": "1.0",
  "timestamp": "2026-10-24T12:30:00.000Z",
  "requestContext": {
    "requestId": "a1b2c3d4-...",
    "functionArn": "arn:aws:lambda:us-east-1:123456789012:function:ProbeWorker:live",
    "condition": "Success",           // "Success" or "RetriesExhausted"
    "approximateInvokeCount": 1       // how many times Lambda tried
  },
  "requestPayload": {                 // the original event
    "endpoint": "https://example.com/mcp",
    "serverId": "example-mcp-server"
  },
  "responseContext": {
    "statusCode": 200,
    "executedVersion": "5"
  },
  "responsePayload": {                // function return value
    "status": "healthy",
    "latencyMs": 142,
    "toolCount": 7,
    "schemaHash": "a3f9..."
  }
}

// OnFailure envelope — responsePayload contains the error
{
  "requestContext": {
    "condition": "RetriesExhausted",
    "approximateInvokeCount": 3       // retried twice before OnFailure fired
  },
  "responsePayload": {
    "errorType": "ProbeTimeoutError",
    "errorMessage": "MCP endpoint did not respond within 30 seconds",
    "stackTrace": ["..."]
  }
}

Contrast with a DLQ: the DLQ message body contains only requestPayload — the original event. The error type, stack trace, and retry count are absent. For debugging a failing probe function, the Destinations envelope provides everything needed to understand the failure without opening CloudWatch Logs.

AliveMCP probe worker architecture with Destinations

AliveMCP's probe worker uses Destinations to maintain a stateless probe function. An EventBridge scheduled rule fires every 60 seconds per monitored endpoint, triggering the probe Lambda asynchronously. The probe connects to the MCP endpoint, runs tools/list, measures latency and schema hash, and returns a result object — but writes nothing to the database. The OnSuccess Destination routes the result to an SQS queue where a separate consumer function batches writes to SQLite. OnFailure routes errors to a dead-letter queue for alert generation.

// CDK: Probe worker with Destinations configured
const probeWorkerFn = new lambda.Function(this, "ProbeWorker", {
  // handler, runtime ...
  retryAttempts: 0,           // no retries — stale probe results are useless
  maxEventAge: Duration.minutes(5), // discard events older than 5 min
});

probeWorkerFn.configureAsyncInvoke({
  onSuccess: new lambdaDestinations.SqsDestination(probeResultsQueue),
  onFailure: new lambdaDestinations.SqsDestination(probeDlq),
  retryAttempts: 0,
  maxEventAge: Duration.minutes(5),
});

The retryAttempts: 0 setting is important for probe workers: the default 2 retries mean a failing probe will be attempted three times over several minutes before the OnFailure destination fires. For uptime monitoring, a failing probe should immediately route to the alert queue — a retry that succeeds on the third attempt produces a false "recovered" signal while the endpoint was actually down during those minutes.

DLQ versus Destinations: choosing between them

FeatureDLQOnFailure Destination
Payload capturedOriginal event onlyOriginal event + error type + stack trace + retry count
Success routingNoYes (OnSuccess)
Supported targetsSQS, SNS onlySQS, SNS, EventBridge, Lambda
Can use both simultaneously?Yes — they are independentYes — configuring both routes failures to both

For new architectures, prefer Destinations — the richer envelope context is almost always useful for debugging and the EventBridge target option enables complex downstream routing. A DLQ is appropriate for backwards compatibility with existing consumers that expect the bare event payload, or when an operations team has established runbooks around DLQ tooling specifically.

Pattern 4 — Lambda ESM Filters: Cost-Efficient Event Processing

The noise-invocation problem

High-throughput event sources — a Kinesis stream receiving every MCP probe attempt, a DynamoDB Streams shard tracking every database write, an SQS queue receiving all probe results — generate records at a volume where most records are not actionable for any given downstream function. Without EventSourceMapping filters, every record invokes the function, and the handler discards irrelevant records early in its logic. This has two costs: Lambda billing for invocations and GB-seconds that do nothing useful, and function throughput consumed processing records that could have been filtered at the source.

ESM FilterCriteria move the relevance check from the handler into the Lambda service layer. Records that do not match any filter pattern are discarded before Lambda invokes the handler — they are not billed as invocations, and they do not consume function concurrency.

Filter syntax and semantics

A filter pattern is a JSON object where each key is a path in the event record and each value is an array of match conditions. A record matches a pattern if all field conditions in that pattern are satisfied — AND semantics within a pattern. A record is processed if it matches any configured filter pattern — OR semantics across patterns.

Condition typeSyntaxExample
Exact string match"value"{"body": {"level": ["ERROR"]}}
String prefix{"prefix": "..."}{"body": {"endpoint": [{"prefix": "https://"}]}}
String suffix{"suffix": "..."}{"body": {"path": [{"suffix": "/mcp"}]}}
Anything-but{"anything-but": [...]}{"body": {"status": [{"anything-but": ["healthy"]}]}}
Numeric range{"numeric": [">", N]}{"body": {"latencyMs": [{"numeric": [">", 5000]}]}}
Exists / not-exists{"exists": true/false}{"body": {"errorCode": [{"exists": true}]}}

The common confusion: placing two fields in one filter pattern creates AND logic (both fields must match). Placing two separate filter objects in the Filters array creates OR logic (either matching pattern triggers Lambda). To trigger Lambda when level == "ERROR" AND latencyMs > 5000, put both conditions in one pattern. To trigger Lambda when level == "ERROR" OR level == "DEGRADED", put them in two separate pattern objects.

SQS filters for MCP probe alert routing

AliveMCP's alert handler function is an SQS consumer that should only run when a probe result indicates a problem. An ESM filter discards healthy probe results before they invoke the handler:

// CDK: SQS ESM with two OR-patterns — ERROR or DEGRADED with high latency
new lambda.EventSourceMapping(this, "AlertHandlerMapping", {
  target: alertHandlerFn,
  eventSourceArn: probeResultsQueue.queueArn,
  batchSize: 10,
  filters: [
    // Pattern 1: any ERROR level probe result
    lambda.FilterCriteria.filter({
      body: {
        level: lambda.FilterRule.isEqual("ERROR"),
      },
    }),
    // Pattern 2: DEGRADED with latency above 5 seconds
    lambda.FilterCriteria.filter({
      body: {
        level: lambda.FilterRule.isEqual("DEGRADED"),
        latencyMs: lambda.FilterRule.greaterThan(5000),
      },
    }),
  ],
});

Non-matching SQS messages (healthy probe results) are automatically deleted from the queue — they do not flow to a DLQ. This is correct behavior: a healthy probe result that is not processed by the alert handler is not a failure. Teams that monitor DLQ depth as a proxy for "all messages processed" need to account for this — filtered-out messages never appear in the DLQ.

DynamoDB Streams filters: INSERT-only and TTL expiry detection

DynamoDB Streams filters require attention to stream view type. To filter on NewImage fields, the table's stream must use NEW_IMAGE or NEW_AND_OLD_IMAGES view type. A KEYS_ONLY stream contains only the primary key in change records — there is no image data for the filter to evaluate, and NewImage filter conditions will never match.

// CDK: DynamoDB Streams filter — only process INSERT of probe_result items
new lambda.EventSourceMapping(this, "ProbeResultStream", {
  target: analyticsWorkerFn,
  eventSourceArn: probeResultsTable.tableStreamArn!,
  startingPosition: lambda.StartingPosition.LATEST,
  batchSize: 50,
  bisectBatchOnError: true,
  filters: [
    // Only INSERT events where itemType attribute == "probe_result"
    lambda.FilterCriteria.filter({
      eventName: lambda.FilterRule.isEqual("INSERT"),
      dynamodb: {
        NewImage: {
          itemType: { S: lambda.FilterRule.isEqual("probe_result") },
        },
      },
    }),
  ],
});

// Separate ESM for TTL expiry cleanup — detects dynamodb.amazonaws.com deletes
// (distinguishes TTL expiry from manual item deletes)
lambda.FilterCriteria.filter({
  eventName: lambda.FilterRule.isEqual("REMOVE"),
  userIdentity: {
    type: lambda.FilterRule.isEqual("Service"),
    principalId: lambda.FilterRule.isEqual("dynamodb.amazonaws.com"),
  },
})

The DynamoDB typed attribute format applies to NewImage filter values: a string attribute itemType is represented as { "S": "probe_result" }, not as a bare string. A numeric attribute ttl is { "N": "1234567890" } where the number is a string in the wire format. Filter conditions must match the DynamoDB JSON representation, not the unmarshalled value.

Cost and throughput impact

MetricWithout filterWith filter (90% filtered out)
Lambda invocations billed100% of record batches~10% — 90% discarded at Lambda service layer
Lambda GB-seconds billedProportional to all recordsProportional to matching records only
Kinesis shard iterator progressAdvances after each batchAdvances after each batch regardless of filters — filtered records do not block the shard
DynamoDB Streams shardAll change events trigger LambdaOnly matching events trigger Lambda; shard reads continue normally

For AliveMCP's Kinesis call-log stream — which receives a record for every probe attempt across thousands of monitored endpoints — filtering to only probe_failure and schema_drift event types reduces invocations by roughly 95% (healthy probes greatly outnumber failures). The Lambda concurrency budget reserved for the alert handler drops proportionally, freeing it for other workloads.

Cross-pattern failure modes

PatternSymptomCauseFix
Response StreamingClient receives empty response bodyresponseStream.end() called before any write, or handler throws before writingWrap handler body in try/finally; write at least one event before end()
Response StreamingSSE events batch-delivered, not streamedReverse proxy (Caddy, CloudFront, ALB) buffering the responseSet X-Accel-Buffering: no; for CloudFront, disable compress on the behavior
Response StreamingAliveMCP timeout on streaming endpointHandler writes no data for >30s (probe timeout)Emit SSE keepalive comment (: keepalive\n\n) every 15s during long processing
Extensionsconnect ECONNREFUSED 127.0.0.1:2773 in handlerParameters and Secrets Extension layer not attached, or failed to startVerify layer ARN matches region AND architecture (arm64 vs x86_64 have separate ARNs)
ExtensionsExtension killed with ExtensionError: Init timed outExtension performed work before registering with Extension APIRegister as the very first async operation, before local server start or any other init
ExtensionsStale secret value after rotationCache TTL has not expired; extension serves cached valueLower SECRETS_MANAGER_TTL; or call ?versionStage=AWSPENDING during rotation Lambda to bust cache
DestinationsOnFailure destination not receiving failuresDefault retryAttempts: 2 — failures are retried twice before OnFailure firesSet retryAttempts: 0 in configureAsyncInvoke for immediate failure routing
DestinationsSQS destination message body not parseableMessage body is the full Destinations envelope, not the original eventParse envelope: JSON.parse(message.Body).responsePayload for function return value
DestinationsDestination not invoked despite function completingFunction was invoked synchronously, not asynchronouslyVerify trigger is async; check invoke type in CloudWatch Logs or add logging
ESM FiltersNo invocations despite records in streamAll records filtered out; filter path wrong or too restrictiveTest filter against sample records using aws lambda test-event-source-mapping
ESM FiltersDynamoDB filter never matches despite correct field valuesStream is KEYS_ONLY — no NewImage in stream recordsChange table stream to NEW_IMAGE or NEW_AND_OLD_IMAGES
ESM FiltersFilter works in test but not productionProduction SQS messages are SNS-wrapped — body structure differs from testAdjust filter path for outer envelope: {"body": {"Message": [...]}} for SNS-to-SQS

Sequencing guidance

For an MCP server team deploying on Lambda for the first time, these four patterns activate in a natural sequence based on product maturity:

  1. ESM Filters first — the easiest win and costs nothing to add to existing EventSourceMappings. Filter SQS and DynamoDB Streams to reduce invocations and simplify handler logic before adding other complexity.
  2. Extensions second — add the Parameters and Secrets Extension once you have a secret to cache. The 150–300 ms cold start overhead is usually acceptable for async probe workers; revisit if you add Provisioned Concurrency later.
  3. Destinations third — add to probe functions once you have a result consumer in place. Setting retryAttempts: 0 and maxEventAge: 5m gives you the right semantics for uptime monitoring from day one.
  4. Response Streaming last — only needed if you ship an SSE-transport endpoint or a tool that produces progressive results. The Function URL invoke mode change is a breaking change that requires all clients to update their endpoint configuration.