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
- Response Streaming — RESPONSE_STREAM mode, streamifyResponse, SSE transport: Create a Function URL with
invokeMode: lambda.InvokeMode.RESPONSE_STREAM. Wrap the handler withawslambda.streamifyResponse(Node.js global, no package needed). Callawslambda.HttpResponseStream.from(responseStream, metadata)before anyresponseStream.write()call to attach status code and headers. SetContent-Type: text/event-streamandX-Accel-Buffering: nofor SSE. Wrap the entire handler body intry/finally { responseStream.end() }— forgettingend()hangs the client until the 15-minute timeout. The streaming payload limit is 20 MB per invocation; paginate large results with a cursor. AliveMCP probes streaming endpoints with a 30-second timeout; write an SSE keepalive comment (: keepalive\n\n) every 15 seconds during long processing to prevent false-positive alerts. - Lambda Extensions — Parameters and Secrets Extension, Telemetry API, cold start overhead: Add the AWS Parameters and Secrets Lambda Extension layer (ARN is region-specific; look up via SSM). In the handler, call
http://localhost:2773/secretsmanager/get?secretId=MySecretwith headerX-Aws-Parameters-Secrets-Token: $AWS_SESSION_TOKEN. The extension caches values for 300 seconds by default; setSECRETS_MANAGER_TTL=600for RDS IAM tokens that expire after 15 minutes. External extensions must register with the Extension API as their first operation before the 10-second Init budget expires. Each extension adds 150–300 ms to cold start; combine with Provisioned Concurrency on high-traffic functions to pay Init cost once at provisioning time. The Telemetry API lets extensions subscribe to function logs, platform events, and extension logs as batched HTTP POST events on a local port. - Lambda Destinations — async only, OnSuccess/OnFailure, payload envelope, DLQ comparison: Destinations only activate on async invocations (SNS, S3, EventBridge,
InvocationType: Event). Configure via CDK'sconfigureAsyncInvoke({ onSuccess: new lambdaDestinations.SqsDestination(queue), onFailure: new lambdaDestinations.SqsDestination(dlq) }). The payload envelope includesrequestContext(requestId, condition: "Success"/"RetriesExhausted", approximateInvokeCount),requestPayload(original event), andresponsePayload(function return value or error). A DLQ captures only the original event; Destinations captures full context. SetretryAttempts: 0for probe workers to prevent duplicate health-check pings. SetmaxEventAge: Duration.minutes(5)— stale probe events are useless. - ESM Filters — FilterCriteria, SQS/Kinesis/DynamoDB, OR/AND semantics, cost: Set
FilterCriteriaon the EventSourceMapping. For SQS, filter onbody(auto-parsed as JSON). For Kinesis, filter ondata(base64-decoded then JSON-parsed). For DynamoDB Streams, filter ondynamodb.NewImage,dynamodb.OldImage, oreventName(INSERT/MODIFY/REMOVE). MultipleFiltersentries use OR semantics; fields within a single filter pattern use AND. Filtered-out records are not billed as Lambda invocations. DynamoDB Streams:NEW_IMAGEorNEW_AND_OLD_IMAGESstream view required forNewImagefilters —KEYS_ONLYhas no image data. SQS: non-matching messages are auto-deleted from the queue (not sent to DLQ) — this is correct behavior.
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).
| Property | BUFFERED (default) | RESPONSE_STREAM |
|---|---|---|
| Response delivery | Entire body sent after handler returns | Chunks sent as written to the stream |
| Max response payload | 6 MB | 20 MB per invocation |
| SSE transport support | No | Yes |
| Header attachment | In return value headers object | Via responseStream.setContentType() before first write |
| CloudWatch extra metrics | None | ResponseStreamingDataLength, 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
| Type | How it runs | Init timing | Shutdown signal |
|---|---|---|---|
| Internal | Same process as the function handler (e.g., via NODE_OPTIONS=--require /opt/extension-init.js) | Runs before handler module is loaded | Same SIGTERM as the function process |
| External | Separate process started by Lambda before the function runtime | Must register with Extension API within 10-second Init budget | SIGTERM + 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 variable | Default | Use case |
|---|---|---|
SECRETS_MANAGER_TTL | 300 s | Lower for rotating credentials; raise for stable static secrets |
SSM_PARAMETER_STORE_TTL | 300 s | Same; SSM SecureString values cached separately from Secrets Manager |
PARAMETERS_SECRETS_EXTENSION_CACHE_SIZE | 1000 | Max 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
| Feature | DLQ | OnFailure Destination |
|---|---|---|
| Payload captured | Original event only | Original event + error type + stack trace + retry count |
| Success routing | No | Yes (OnSuccess) |
| Supported targets | SQS, SNS only | SQS, SNS, EventBridge, Lambda |
| Can use both simultaneously? | Yes — they are independent | Yes — 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 type | Syntax | Example |
|---|---|---|
| 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
| Metric | Without filter | With filter (90% filtered out) |
|---|---|---|
| Lambda invocations billed | 100% of record batches | ~10% — 90% discarded at Lambda service layer |
| Lambda GB-seconds billed | Proportional to all records | Proportional to matching records only |
| Kinesis shard iterator progress | Advances after each batch | Advances after each batch regardless of filters — filtered records do not block the shard |
| DynamoDB Streams shard | All change events trigger Lambda | Only 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
| Pattern | Symptom | Cause | Fix |
|---|---|---|---|
| Response Streaming | 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 event before end() |
| Response Streaming | SSE events batch-delivered, not streamed | Reverse proxy (Caddy, CloudFront, ALB) buffering the response | Set X-Accel-Buffering: no; for CloudFront, disable compress on the behavior |
| Response Streaming | AliveMCP timeout on streaming endpoint | Handler writes no data for >30s (probe timeout) | Emit SSE keepalive comment (: keepalive\n\n) every 15s during long processing |
| Extensions | connect ECONNREFUSED 127.0.0.1:2773 in handler | Parameters and Secrets Extension layer not attached, or failed to start | Verify layer ARN matches region AND architecture (arm64 vs x86_64 have separate ARNs) |
| Extensions | Extension killed with ExtensionError: Init timed out | Extension performed work before registering with Extension API | Register as the very first async operation, before local server start or any other init |
| Extensions | Stale secret value after rotation | Cache TTL has not expired; extension serves cached value | Lower SECRETS_MANAGER_TTL; or call ?versionStage=AWSPENDING during rotation Lambda to bust cache |
| Destinations | OnFailure destination not receiving failures | Default retryAttempts: 2 — failures are retried twice before OnFailure fires | Set retryAttempts: 0 in configureAsyncInvoke for immediate failure routing |
| Destinations | SQS destination message body not parseable | Message body is the full Destinations envelope, not the original event | Parse envelope: JSON.parse(message.Body).responsePayload for function return value |
| Destinations | Destination not invoked despite function completing | Function was invoked synchronously, not asynchronously | Verify trigger is async; check invoke type in CloudWatch Logs or add logging |
| ESM Filters | No invocations despite records in stream | All records filtered out; filter path wrong or too restrictive | Test filter against sample records using aws lambda test-event-source-mapping |
| ESM Filters | DynamoDB filter never matches despite correct field values | Stream is KEYS_ONLY — no NewImage in stream records | Change table stream to NEW_IMAGE or NEW_AND_OLD_IMAGES |
| ESM Filters | Filter works in test but not production | Production SQS messages are SNS-wrapped — body structure differs from test | Adjust 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:
- 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.
- 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.
- Destinations third — add to probe functions once you have a result consumer in place. Setting
retryAttempts: 0andmaxEventAge: 5mgives you the right semantics for uptime monitoring from day one. - 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.