Guide · AWS Kinesis

Kinesis Consumer Patterns for MCP Servers

Kinesis Data Streams supports three consumer architectures — Lambda event source mapping for simple fan-in, KCL workers for stateful stream processing, and enhanced fan-out for multiple high-throughput consumers — and each has different failure-handling semantics that determine how your MCP event pipeline behaves when one record is malformed or one downstream call fails. Three things teams consistently get wrong: Lambda ESM retries the entire batch on failure (not just the failing record — a single bad record blocks all records behind it on the same shard until the batch expires or the retention window ends, unless you enable bisect-on-error and report per-record failures), KCL checkpointing advances past unprocessed records (checkpointing after processing a batch marks all records in the batch as consumed — if your code processes records 1–4 and checkpoints before attempting record 5, a crash loses record 5 permanently), and enhanced fan-out consumers must re-subscribe after 5 minutes (SubscribeToShard connections expire — not re-subscribing makes the consumer silently stall with no error).

TL;DR

Use Lambda ESM with BisectBatchOnFunctionError: true, DestinationConfig pointing to an SQS dead-letter queue, and report item-level failures via batchItemFailures to avoid reprocessing the entire batch on one bad record. Use KCL v3 only when you need stateful aggregation across records or multi-shard joins. Use enhanced fan-out when you have more than two independent consumers reading the same stream and cannot share the 2 MB/s standard read throughput. Design all consumers for idempotency — Kinesis guarantees at-least-once delivery, not exactly-once.

Lambda Event Source Mapping — configuration and failure handling

Lambda ESM is the lowest-operational-overhead way to consume a Kinesis stream. Lambda polls the stream on your behalf (using enhanced fan-out by default since 2023) and invokes your function with a batch of records from a single shard. You pay for Lambda invocation time; no consumer infrastructure to run.

The most important configuration is bisect-on-error. Without it, a single record that causes your Lambda to throw an error causes the entire batch to be retried — and since Kinesis ordering is per-shard, the batch blocks all later records on that shard. With bisect-on-error enabled, Lambda splits a failing batch in half and retries each half independently, rapidly isolating the single bad record. Combined with item-level failure reporting, you can process 499 good records and send only the 1 bad record to the DLQ.

import type { KinesisStreamEvent, KinesisStreamBatchResponse } from "aws-lambda";

export async function handler(
  event: KinesisStreamEvent
): Promise<KinesisStreamBatchResponse> {
  const failures: Array<{ itemIdentifier: string }> = [];

  for (const record of event.Records) {
    try {
      const payload = JSON.parse(
        Buffer.from(record.kinesis.data, "base64").toString("utf-8")
      );

      await processToolCallEvent(payload, record.kinesis.sequenceNumber);
    } catch (err) {
      console.error(`Failed to process record ${record.kinesis.sequenceNumber}:`, err);
      // Report this record as failed without failing the entire batch
      failures.push({ itemIdentifier: record.kinesis.sequenceNumber });
    }
  }

  // Return batchItemFailures — Lambda retries only these records
  // Records NOT in this list are considered successfully processed
  return { batchItemFailures: failures };
}

async function processToolCallEvent(
  payload: unknown,
  sequenceNumber: string
): Promise<void> {
  // Idempotency: use sequenceNumber as idempotency key
  // DynamoDB conditional write: only insert if sequenceNumber not already present
  // This handles Lambda ESM's at-least-once delivery guarantee
  // ...
}

Lambda ESM configuration best practices for Kinesis:

# CDK configuration for Kinesis ESM
import { EventSourceMapping, StartingPosition } from "aws-cdk-lib/aws-lambda";
import { SqsDlq } from "aws-cdk-lib/aws-lambda-event-sources";
import { KinesisEventSource } from "aws-cdk-lib/aws-lambda-event-sources";

processorFn.addEventSource(
  new KinesisEventSource(stream, {
    startingPosition: StartingPosition.TRIM_HORIZON,
    batchSize: 100,
    maxBatchingWindow: Duration.seconds(5),
    bisectBatchOnError: true,
    reportBatchItemFailures: true,
    parallelizationFactor: 1,         // increase for throughput over ordering
    retryAttempts: 3,                 // retry each bisected batch up to 3x before DLQ
    onFailure: new SqsDlq(dlqQueue),
  })
);

KCL worker model — when to use it and checkpointing semantics

The Kinesis Client Library (KCL) is a Java/Python/Node.js library that manages shard assignment, lease coordination across multiple worker instances, and checkpointing to DynamoDB. Use KCL over Lambda ESM when you need: stateful aggregation across records (windowed counts, session stitching), processing that must run on a long-lived server rather than Lambda, or multi-shard joins that require a global view of the stream.

The checkpointing semantic is the key operational concept: KCL tracks progress by writing a sequence number to DynamoDB (the lease table). When you call checkpointer.checkpoint(), KCL records the sequence number of the last record you passed. If your process crashes after checkpoint, the next KCL worker picks up from after that sequence number. If you crash before checkpoint, the next worker reprocesses everything since the last successful checkpoint — at-least-once delivery. Never checkpoint after processing each record in a loop — always checkpoint after the entire batch to minimize DynamoDB write overhead. Never checkpoint before confirming all records in the batch succeeded — partial batches with a checkpoint lose the unprocessed tail.

// KCL v3 TypeScript worker (Node.js 22)
// npm install amazon-kinesis-client

import { Checkpointer, InitializationInput, ProcessRecordsInput, ShutdownInput } from "amazon-kinesis-client";

class McpEventProcessor {
  private shardId = "";

  async initialize(input: InitializationInput): Promise<void> {
    this.shardId = input.shardId;
    console.log(`Initialized shard ${this.shardId} at ${input.extendedSequenceNumber}`);
  }

  async processRecords(input: ProcessRecordsInput): Promise<void> {
    const { records, checkpointer } = input;

    const processed: string[] = [];

    for (const record of records) {
      try {
        const payload = JSON.parse(record.data.toString("utf-8"));
        await processEvent(payload);
        processed.push(record.sequenceNumber);
      } catch (err) {
        console.error(`Error processing ${record.sequenceNumber}:`, err);
        // Don't checkpoint past the failing record
        // Break here to reprocess from last checkpoint on restart
        break;
      }
    }

    // Checkpoint only if we processed records — and only after ALL succeeded
    if (processed.length === records.length) {
      const lastSeq = records[records.length - 1].sequenceNumber;
      await checkpointer.checkpoint(lastSeq);
    }
  }

  async leaseLost(_input: unknown): Promise<void> {
    console.log(`Shard ${this.shardId} lease lost — another worker took over`);
  }

  async shardEnded(input: { checkpointer: Checkpointer }): Promise<void> {
    // Must checkpoint with SHARD_END to signal shard is fully processed
    // Required after shard split/merge so KCL advances to child shards
    await input.checkpointer.checkpoint();
    console.log(`Shard ${this.shardId} ended`);
  }

  async shutdownRequested(input: { checkpointer: Checkpointer }): Promise<void> {
    await input.checkpointer.checkpoint();
  }
}

Enhanced fan-out with SubscribeToShard

Enhanced fan-out consumers register with RegisterStreamConsumer and receive records via a long-lived HTTP/2 push connection (SubscribeToShard). Unlike standard GetRecords polling (shared 2 MB/s across all consumers), each enhanced fan-out consumer gets a dedicated 2 MB/s per shard pipe. Maximum 20 enhanced fan-out consumers per stream.

The re-subscribe loop trap: SubscribeToShard connections automatically expire after 5 minutes. The SDK raises an event when the subscription ends — you must handle this event and immediately call SubscribeToShard again. If you miss this event handler, the consumer silently stops receiving records with no error thrown on the producer side.

import {
  KinesisClient,
  SubscribeToShardCommand,
  StartingPosition,
} from "@aws-sdk/client-kinesis";

const kinesis = new KinesisClient({ region: "us-east-1" });

async function subscribeToShard(
  consumerArn: string,
  shardId: string,
  lastSequenceNumber?: string
): Promise<void> {
  const startingPosition = lastSequenceNumber
    ? {
        Type: "AFTER_SEQUENCE_NUMBER" as const,
        SequenceNumber: lastSequenceNumber,
      }
    : { Type: "LATEST" as const };

  // SubscribeToShard returns an async iterable of event batches
  const response = await kinesis.send(
    new SubscribeToShardCommand({
      ConsumerARN: consumerArn,
      ShardId: shardId,
      StartingPosition: startingPosition,
    })
  );

  let lastProcessedSeq = lastSequenceNumber;

  try {
    for await (const event of response.EventStream!) {
      if (event.SubscribeToShardEvent) {
        const { Records, ContinuationSequenceNumber } = event.SubscribeToShardEvent;

        for (const record of Records ?? []) {
          const payload = JSON.parse(
            Buffer.from(record.Data!).toString("utf-8")
          );
          await processEvent(payload);
          lastProcessedSeq = record.SequenceNumber;
        }
      }
    }
  } catch (err: unknown) {
    const errName = (err as Error).name ?? "";
    if (errName === "ResourceInUseException") {
      // Another consumer has this shard — back off and retry
      await new Promise((r) => setTimeout(r, 5000));
    }
    // All other errors: fall through to re-subscribe
  }

  // Subscription expired (5-min limit) or error — re-subscribe immediately
  await subscribeToShard(consumerArn, shardId, lastProcessedSeq);
}

Idempotent processing — handling at-least-once delivery

Kinesis guarantees that every record is delivered at least once but does not guarantee exactly once. Duplicate delivery occurs: after a Lambda ESM retry, after a KCL worker failover before checkpoint, or after a network interruption that causes a partial batch to be re-delivered. Your consumer must be idempotent — processing the same record twice must produce the same outcome as processing it once.

The cleanest idempotency mechanism for MCP server event consumers is a conditional DynamoDB write keyed on the Kinesis sequence number:

import { DynamoDBClient, PutItemCommand, ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";

const dynamo = new DynamoDBClient({ region: "us-east-1" });

async function processEventIdempotently(
  payload: Record<string, unknown>,
  sequenceNumber: string
): Promise<void> {
  // Conditional write: only proceed if this sequence number hasn't been processed
  try {
    await dynamo.send(
      new PutItemCommand({
        TableName: "mcp-processed-events",
        Item: marshall({
          pk: `seq#${sequenceNumber}`,
          processedAt: new Date().toISOString(),
          ttl: Math.floor(Date.now() / 1000) + 7 * 24 * 3600, // expire after 7 days
        }),
        ConditionExpression: "attribute_not_exists(pk)",
      })
    );
  } catch (err) {
    if (err instanceof ConditionalCheckFailedException) {
      // Already processed — idempotent skip
      return;
    }
    throw err;
  }

  // Only reaches here if this is the first time processing this sequence number
  await doActualProcessing(payload);
}

Consumer pattern selection guide

RequirementRecommended patternWhy
Simple event routing — 1 consumer Lambda ESM with bisect-on-error No infrastructure, auto-scales with stream, lowest ops burden
Archive to S3 / Redshift Kinesis Firehose as stream consumer Managed delivery, no consumer code, dynamic partitioning
Multiple independent consumers (>2) Enhanced fan-out consumers Dedicated 2 MB/s per consumer; no throughput sharing
Stateful windowed aggregation KCL v3 worker on EC2 / ECS Long-lived process with state; shard-level lease coordination
Real-time ML inference on stream Lambda ESM + SageMaker endpoint per batch Scales with stream; Lambda handles shard assignment
Cross-stream join KCL or Managed Flink (Kinesis Data Analytics) Requires stateful operator — Lambda is stateless per invocation

Common failure modes reference

Error / symptomRoot causeFix
Lambda ESM shard blocked — no new records processed Single bad record causing whole-batch retry without bisect Enable BisectBatchOnFunctionError and report batchItemFailures
Records lost after KCL worker restart Checkpoint called before all records in batch processed Only checkpoint after full batch success; break on error without checkpointing
Enhanced fan-out consumer stalls silently SubscribeToShard 5-minute expiry not handled Re-subscribe in a loop after each SubscribeToShard stream ends
Duplicate records processed (downstream side-effects twice) At-least-once delivery without idempotency check Conditional DynamoDB write keyed on sequence number before processing
KCL lease table costs unexpectedly high Too many KCL workers contending for same shards Number of KCL workers should equal number of shards — 1 worker per shard max
Records not processed after shard split Consumer doesn't follow child shards after parent shard closes KCL handles this automatically; custom consumers must handle shardEnded and read child shard descriptors