Event-Driven & Async Patterns · 2026-07-31 · Event-Driven arc

Event-Driven MCP Servers: Delivery Guarantees, Event Envelopes, and Consumer Group Patterns

Five event-driven technologies — Dapr, AWS EventBridge, CloudEvents, Redis Streams, and Event Sourcing — appear in MCP servers for the same reason: agents need to publish domain events, consume message queues, and query durable event logs without the MCP server becoming a synchronous bottleneck. Delivery guarantee model is the first and most consequential pattern, because the five technologies diverge sharply on what "success" means after you hand off an event: AWS EventBridge returns HTTP 200 from PutEvents but that means only that the event was accepted by the service — whether any downstream Lambda or Step Function received and processed it is not communicated back to your tool handler; a FailedEntryCount greater than zero in the same HTTP 200 response indicates that specific entries in your batch were rejected (invalid JSON in Detail, missing required fields, or event bus not found), and the only way to know which entries failed is to inspect the Entries array where failed entries carry ErrorCode and ErrorMessage while successful entries carry an EventId; Dapr pub/sub is at-least-once by design — the sidecar retries delivery to your subscription endpoint until your handler returns { status: 'SUCCESS' }, meaning messages can be delivered more than once in crash-recovery scenarios and every handler must be idempotent; Redis Streams consumer groups are also at-least-once with an explicit Pending Entries List — when a consumer calls XREADGROUP and receives entries, those entries move into that consumer's PEL and stay there until XACK is called; if the consumer crashes, the entries remain in the PEL attributed to the dead consumer and will not be re-delivered to surviving consumers unless you explicitly run XAUTOCLAIM to transfer stuck entries; Event Sourcing achieves exactly-once write semantics through optimistic concurrency — a UNIQUE (aggregate_id, sequence_number) constraint on the events table means two concurrent writers both trying to append sequence_number = 5 for the same aggregate will produce a constraint violation for one of them, preventing duplicate events from entering the log; CloudEvents is a transport-agnostic envelope specification and has no delivery guarantee of its own — the guarantee comes from whatever broker carries the CloudEvent, so a CloudEvent delivered over HTTP to an endpoint that returns 5xx will be retried by brokers that follow the CloudEvents HTTP binding's retry guidance, while a CloudEvent published to EventBridge carries EventBridge's fire-and-forget semantics. Event envelope standardization is the second pattern, and the source of integration bugs that span multiple services: CloudEvents 1.0 defines a minimal required attribute set (specversion, id, source, type) and two HTTP content modes (structured mode where both envelope and data travel in a single JSON body with Content-Type: application/cloudevents+json, and binary mode where envelope attributes travel as ce-* HTTP headers and only the data travels in the body) — binary mode is preferred for performance but HTTP proxies, load balancers, and CDNs that strip non-standard headers will silently drop every ce-* attribute, leaving the receiver with a bodyful of data and no event context; Dapr's pub/sub system wraps your published payload inside a CloudEvents 1.0 envelope automatically — the event your subscription handler receives has specversion, type, source, id, time, topic, and pubsubname added by the sidecar, and your original payload is in event.data, not at the envelope root; AWS EventBridge uses its own proprietary shape that has nothing to do with the CloudEvents spec — you call PutEvents with Source (reverse-DNS by convention, e.g. com.example.orders), DetailType (human-readable event name like order.created), and Detail (a JSON-serialized string, not a JSON object — passing an object will produce a runtime type error from the AWS SDK); Redis Streams have no envelope at all — entries are flat key-value maps where all values must be strings, so you define your own envelope convention (typically type, payload as JSON string, timestamp as ISO string) and enforce it at the application level; Event Sourcing also defines its own envelope, but at the database column level — event_type, event_version, payload (JSONB in PostgreSQL), and metadata (for correlation_id, causation_id) are the conventional fields, and the sequence_number per aggregate stream is the critical ordering key. Consumer group and acknowledgment patterns are the third pattern, and the one where operational failures are hardest to detect without instrumentation: Redis Streams exposes the Pending Entries List per consumer and per group via XPENDING, which lets you measure exactly how many messages each consumer has received but not acknowledged and how long they have been idle — a PEL entry idle for more than 60 seconds is a strong signal that the consumer crashed mid-processing, and XAUTOCLAIM is the recovery operation that transfers those stuck entries to a live consumer for reprocessing; Dapr handles acknowledgment through the HTTP response status of your subscription endpoint — returning { status: 'DROP' } is the Dapr equivalent of a dead-letter operation, and returning { status: 'RETRY' } asks the sidecar to re-deliver after its configured backoff policy; the health probe for Dapr's sidecar is the outbound variant (/v1.0/healthz/outbound returns 204 when the sidecar and all its configured component connections are healthy, unlike /v1.0/healthz which only checks the sidecar process itself); Event Sourcing projectors maintain their own checkpoint — a projector_checkpoints table records each projector's last_event_id, which lets the projector resume from the correct position after a restart and supports parallel projectors building different read-model tables from the same event log. This post covers all three patterns with annotated code for each technology, a health probe comparison table for operational runbooks, and a technology selection matrix for eight common MCP server event-driven use cases.

TL;DR

Five technologies, three patterns. (1) Delivery guarantees: EventBridge — fire-and-forget; HTTP 200 is not delivery confirmation, always check FailedEntryCount; Dapr pub/sub — at-least-once, return { status: 'SUCCESS' } only after processing completes; Redis Streams — at-least-once via PEL; call XACK after processing, run XAUTOCLAIM periodically for dead-consumer recovery; Event Sourcing — exactly-once write via UNIQUE (aggregate_id, sequence_number) constraint; CloudEvents — no inherent guarantee, depends on underlying transport broker. (2) Event envelopes: CloudEvents 1.0 — specversion must be string '1.0', not number; type uses reverse-DNS convention; prefer structured mode over binary mode when traversing HTTP proxies; Dapr — wraps payload in CloudEvents automatically, your data is in event.data; EventBridge — proprietary PutEvents shape, Detail must be a JSON string not an object; Redis Streams — flat key-value, define your own type/payload convention; Event Sourcing — column-level schema with event_type, event_version, payload JSONB. (3) Consumer groups / acknowledgment: Redis Streams — explicit XACK, monitor PEL lag, XAUTOCLAIM for dead consumers; Dapr — return RETRY or DROP from subscription handler; EventBridge — no consumer-side ack, use DLQ at target Lambda/SQS; Event Sourcing — projector checkpoints in projector_checkpoints table, idempotent projections; CloudEvents — HTTP 200 = acknowledged, 4xx = reject (no retry), 5xx = retry.

Pattern 1 — Delivery Guarantees: What "Success" Actually Means

The most dangerous assumption in event-driven MCP server design is that a successful publish call means the event was delivered and processed. Each of the five technologies defines "success" differently, and the gap between your assumption and the actual guarantee is where silent failures live.

AWS EventBridge — fire-and-forget with a hidden partial-failure channel

EventBridge's PutEvents API accepts events and immediately returns HTTP 200 — it does not wait for downstream processing. No Lambda execution result, no Step Function state transition, no DynamoDB write confirmation comes back to your tool handler. This is intentional: EventBridge is a routing bus, not a synchronous RPC mechanism. The tool that calls PutEvents is done the moment EventBridge accepts the events.

The partial-failure trap is subtler: PutEvents returns HTTP 200 even when individual entries in a batch are rejected. The FailedEntryCount field in the response is the true signal — a value greater than zero means at least one entry was not accepted. Failed entries appear in the Entries response array with ErrorCode and ErrorMessage; successful entries have an EventId. Treating HTTP 200 as success without checking FailedEntryCount silently loses events.

// WRONG — treats HTTP 200 as delivery confirmation
const response = await eb.send(new PutEventsCommand({ Entries: events }));
return { published: true };  // may have FailedEntryCount: 1

// CORRECT — check FailedEntryCount, inspect failed entries
const response = await eb.send(new PutEventsCommand({ Entries: events }));
if ((response.FailedEntryCount ?? 0) > 0) {
  const failures = response.Entries
    ?.filter(e => e.ErrorCode)
    .map(e => ({ code: e.ErrorCode, message: e.ErrorMessage }));
  return { published: false, failures };
}
return { published: true, event_ids: response.Entries?.map(e => e.EventId) };

Because EventBridge is fire-and-forget, MCP tools that need to confirm a downstream effect — "did the order processing Lambda succeed?" — must query the target system directly after a short delay or poll a shared state store. The event bus itself will never report back.

Dapr pub/sub — at-least-once with explicit retry and drop semantics

Dapr delivers messages to subscription endpoints at-least-once. The Dapr sidecar calls your subscription HTTP endpoint and waits for a response. Your handler's return value controls what happens next:

The at-least-once guarantee means your handler may receive the same event more than once — after a crash, network partition, or timeout where Dapr did not receive a SUCCESS response before its own deadline. Every subscription handler must be idempotent. The CloudEvents id field (set by Dapr on the envelope) is the deduplication key — record it in your state store before returning SUCCESS to enable idempotent processing.

// Subscription handler — must be idempotent
await daprServer.pubsub.subscribe(PUBSUB_NAME, 'order-events', async (cloudEvent) => {
  const payload = cloudEvent.data;   // your published payload is in data, not at root
  const dedupKey = `dedup:${cloudEvent.id}`;

  // Check if already processed — the id is stable across Dapr retries
  const { data: existing } = await daprClient.state.getStateAndEtag(STATE_STORE, dedupKey);
  if (existing) {
    return { status: 'SUCCESS' };  // already processed — safe to acknowledge again
  }

  try {
    await processOrderEvent(payload);
    await daprClient.state.save(STATE_STORE, [{
      key: dedupKey,
      value: { processed_at: new Date().toISOString() },
      metadata: { ttlInSeconds: '86400' },  // auto-expire after 24h
    }]);
    return { status: 'SUCCESS' };
  } catch (err) {
    if (isTransientError(err)) return { status: 'RETRY' };
    return { status: 'DROP' };   // poison message — log before dropping
  }
});

Dapr's retry policy is configured in the component YAML, not in code. The default is three retries with exponential backoff. You can override this with a resiliency policy YAML that specifies maxRetries, retryBackoff, and timeout — separate from the component definition, applied by namespace or app-id.

Redis Streams — at-least-once via the Pending Entries List

Redis Streams consumer groups achieve at-least-once delivery through the Pending Entries List. When XREADGROUP delivers entries to a consumer, those entries move from the stream into the consumer's PEL — a per-consumer record of "delivered but not yet acknowledged" entries. XACK removes an entry from the PEL; without it, the entry stays there indefinitely.

The critical failure mode is dead consumers. If a consumer process crashes after receiving entries but before calling XACK, those entries remain in that consumer's PEL. Other consumers in the group receive only new entries (using > in XREADGROUP) — they do not see the dead consumer's PEL entries. Without active recovery, those entries are effectively stuck. XAUTOCLAIM (Redis 6.2+) is the recovery command — it transfers entries idle in the PEL for longer than a configurable threshold to a new consumer:

// Schedule this to run periodically — e.g., every 30 seconds
async function reclaimStuckEntries(consumerName: string): Promise<void> {
  const MIN_IDLE_MS = 60_000;  // 60 seconds idle = likely dead consumer

  // Transfer stuck entries to this (active) consumer
  const result = await redis.xautoclaim(
    STREAM_NAME, CONSUMER_GROUP, consumerName,
    MIN_IDLE_MS,
    '0-0',       // start from beginning of PEL
    'COUNT', 50
  ) as [string, Array<[string, string[]]>, string[]];

  const [nextCursor, claimed] = result;
  if (claimed.length > 0) {
    console.log(`Reclaimed ${claimed.length} stuck entries from PEL`);
    // Now process claimed entries with the normal handler
    for (const [id, fields] of claimed) {
      await processStreamEntry(id, fields);
      await redis.xack(STREAM_NAME, CONSUMER_GROUP, id);
    }
  }
}

Because XAUTOCLAIM causes re-delivery, every stream entry processor must be idempotent. The entry ID (a millisecond timestamp plus sequence counter, e.g. 1688000000000-0) is stable across re-delivery and is the natural deduplication key.

Event Sourcing — exactly-once writes via optimistic concurrency

Event Sourcing is the only technology in this group that offers exactly-once write semantics without external coordination. The mechanism is a UNIQUE (aggregate_id, sequence_number) constraint on the events table. Each append operation specifies the expected sequence_number (the next sequence after the caller's last read) — if another writer has already appended at that sequence number, the database raises a unique constraint violation, and the second writer knows to reload the aggregate and retry with a new sequence number.

// Exactly-once append via optimistic concurrency
async function appendEvent(
  aggregateId: string,
  expectedSeq: number,
  eventType: string,
  payload: Record<string, unknown>
): Promise<number> {
  const nextSeq = expectedSeq + 1;
  try {
    const { rows } = await db.query(
      `INSERT INTO events
         (aggregate_id, aggregate_type, sequence_number, event_type, payload)
       VALUES ($1, $2, $3, $4, $5)
       RETURNING sequence_number`,
      [aggregateId, 'order', nextSeq, eventType, payload]
    );
    return rows[0].sequence_number;
  } catch (err: any) {
    if (err.code === '23505') {  // PostgreSQL unique violation
      throw new OptimisticConcurrencyError(
        `Aggregate ${aggregateId}: expected seq ${expectedSeq} but another writer advanced it.`
      );
    }
    throw err;
  }
}

The caller retries by re-loading the aggregate (replaying events to get the current sequence number) and attempting the append again. This is the Event Sourcing equivalent of a CAS (compare-and-swap) operation — it serializes concurrent writes to the same aggregate stream without distributed locks.

CloudEvents — no inherent guarantee; the broker defines it

CloudEvents is an envelope specification, not a delivery system. A CloudEvent transmitted over bare HTTP with no broker has no delivery guarantee — if the receiver returns 500, whether there is a retry depends on your calling code. A CloudEvent published to EventBridge inherits EventBridge's fire-and-forget semantics. A CloudEvent delivered by Knative Eventing to a subscriber endpoint is retried by Knative according to its retry policy when the endpoint returns 5xx. The CloudEvents spec's HTTP binding guidance says 2xx = processed, 4xx = reject without retry, 5xx = retry — but only brokers that implement this guidance will follow it.

Pattern 2 — Event Envelope Standardization: Who Adds What, and What Does "Detail" Mean

When five different event-driven systems arrive in the same MCP server codebase, the most common integration error is treating one system's envelope convention as if it applied to another. The fields differ, the type constraints differ (JSON object vs JSON string vs flat key-value), and the version-safety mechanisms differ.

CloudEvents 1.0 — the CNCF standard and its version trap

CloudEvents 1.0 defines four required attributes — specversion, id, source, type — and a set of optional attributes (time, subject, datacontenttype, dataschema). The most common validation mistake is failing to check specversion before processing a received event.

CloudEvents 0.3 — still in the wild in systems that were built before 1.0 was finalized — uses different attribute names: schemaurl instead of dataschema, contenttype instead of datacontenttype. A receiver built for 1.0 will either miss these fields silently or fail validation when a 0.3 event arrives. Always validate specversion === '1.0' first and return a 422 (not a 400) for version mismatches so the caller can distinguish "malformed event" from "wrong spec version."

import { HTTP, CloudEvent } from 'cloudevents';
import express from 'express';

const app = express();
app.use(express.raw({ type: '*/*' }));  // preserve raw body — cloudevents parsing needs it

app.post('/events', async (req, res) => {
  let event: CloudEvent<unknown>;
  try {
    // HTTP.toEvent auto-detects structured vs binary content mode
    event = HTTP.toEvent({ headers: req.headers, body: req.body });
  } catch (err: any) {
    res.status(400).json({ error: 'invalid_cloud_event', message: err.message });
    return;
  }

  if (event.specversion !== '1.0') {
    // 0.3 events will have specversion '0.3' — explicit version check required
    res.status(422).json({
      error: 'unsupported_specversion',
      message: `Expected '1.0', got '${event.specversion}'. CloudEvents 0.3 is not supported.`,
    });
    return;
  }

  // For unknown event types: return 200 (not 4xx) — brokers should not retry unknown types
  if (!event.type.startsWith('com.example.')) {
    res.status(200).json({ status: 'ignored', type: event.type });
    return;
  }

  await routeEvent(event);
  res.status(200).json({ status: 'processed', event_id: event.id });
});

Structured vs binary content mode is the second CloudEvents envelope decision. In structured mode, the entire CloudEvent (attributes + data) travels as a single JSON body with Content-Type: application/cloudevents+json. In binary mode, attributes travel as ce-id, ce-source, ce-type, etc. HTTP headers, and only the data travels in the body. Binary mode is more efficient for large payloads but any HTTP proxy, CDN, or load balancer that strips non-standard headers will silently drop all event context from binary-mode events. Use structured mode when you do not control the path between publisher and receiver; use binary mode only within a trusted network.

Dapr — CloudEvents envelope added by the sidecar

When you publish to a Dapr topic, the Dapr sidecar wraps your payload in a CloudEvents 1.0 envelope before delivering it to subscribers. The original payload you passed to daprClient.pubsub.publish() arrives at the subscription handler in the data field of the CloudEvent — not at the root of the received JSON.

// Publishing — you send your payload
await daprClient.pubsub.publish(PUBSUB_NAME, 'order-events', {
  orderId: '123',
  total: 99.95,
  currency: 'USD',
});

// Receiving — Dapr wraps it; extract event.data for your payload
await daprServer.pubsub.subscribe(PUBSUB_NAME, 'order-events', async (cloudEvent) => {
  // cloudEvent structure:
  // {
  //   specversion: '1.0',       ← added by Dapr
  //   id: 'uuid-...',           ← added by Dapr (use for dedup)
  //   type: 'com.dapr.event.sent',  ← added by Dapr
  //   source: 'pubsub-name/order-events',  ← added by Dapr
  //   topic: 'order-events',    ← added by Dapr
  //   pubsubname: 'pubsub',     ← added by Dapr
  //   datacontenttype: 'application/json',
  //   data: { orderId: '123', total: 99.95, currency: 'USD' }  ← your payload
  // }
  const { orderId, total, currency } = cloudEvent.data as { orderId: string; total: number; currency: string };
  // ...
});

If you need to bypass the CloudEvents envelope — for example, when consuming raw binary messages from a Kafka topic where the messages were produced by non-Dapr code — set rawPayload: 'true' in the subscription metadata. The raw bytes arrive as a base64-encoded string in the handler parameter.

Component names (the first argument to publish()) come from Dapr component YAML files, not from code. This is the architectural benefit of the Dapr sidecar model: the same MCP server TypeScript code publishes to Redis Streams in development and Kafka in production by changing only the component YAML. No SDK imports for Kafka or Redis live in your application code; the sidecar handles the protocol translation.

AWS EventBridge — proprietary envelope with a JSON-string Detail trap

EventBridge's event shape has nothing in common with CloudEvents. A PutEvents entry has five fields that you control: Source (string, convention is reverse-DNS like com.example.orders), DetailType (human-readable description of the event occurrence like order.created), Detail (a JSON-serialized string — not a JSON object), EventBusName, and optional Resources (ARN list).

The Detail field is the most common source of integration bugs: passing a JavaScript object where a JSON string is expected throws a runtime type error from the AWS SDK, and the reverse (double-serializing a string) produces an escaped-string body that downstream consumers cannot parse. Always use JSON.stringify(yourObject) explicitly.

// WRONG — passes object where string is required
Entries: [{
  Source: 'com.example.orders',
  DetailType: 'order.created',
  Detail: { orderId: '123', total: 99.95 },  // TypeError at runtime
}]

// CORRECT — serialize to JSON string
Entries: [{
  Source:     'com.example.orders',
  DetailType: 'order.created',
  Detail:     JSON.stringify({ orderId: '123', total: 99.95 }),  // required
  EventBusName: 'orders-bus',  // always use a custom bus, not 'default'
}]

EventBridge event patterns match by field value against the event envelope — patterns check source, detail-type, and arbitrary fields inside detail. Pattern matching is partial: a pattern { "source": ["com.example.orders"] } matches any event with that source regardless of other fields. This means you do not need to enumerate every field in a filter pattern, only the ones you want to constrain.

Always create a custom event bus per bounded context. The default bus receives all AWS service events automatically — CloudTrail audit records, EC2 state changes, S3 object events — and mixing application events with AWS service events on the default bus complicates IAM policies and makes event routing rules harder to maintain. Custom buses require an explicit PutEvents call to receive any events and can have tighter IAM resource policies.

Redis Streams — no standard envelope; define your own

Redis Streams entries are flat key-value maps where all values must be strings. There is no built-in envelope, no specversion, no id field, no schema registry. You define the schema by convention:

// Convention: three fields per entry
await redis.xadd(
  STREAM_NAME,
  'MAXLEN', '~', String(MAX_LEN),  // O(1) trim
  '*',                              // auto-generate entry ID (timestamp + seq)
  'type',      'order.created',
  'payload',   JSON.stringify({ orderId: '123', total: 99.95 }),
  'timestamp', new Date().toISOString(),
);

// Reading: deserialize the flat array into a typed object
const entries = await redis.xreadgroup(/* ... */) as Array<[string, Array<[string, string[]]>]> | null;
for (const [id, fields] of entries[0][1]) {
  const obj: Record<string, unknown> = { id };
  for (let i = 0; i < fields.length; i += 2) {
    const key = fields[i];
    const val = fields[i + 1];
    obj[key] = key === 'payload' ? JSON.parse(val) : val;
  }
}

The auto-generated entry ID (1688000000000-0) encodes the millisecond timestamp and a per-millisecond sequence counter, providing total ordering within a single Redis node. This ID serves as the deduplication key for idempotent handlers. Stream entries do not have an expiry by default — use MAXLEN ~ N in XADD to bound memory, or a separate XTRIM command for periodic cleanup.

Event Sourcing — column-level schema with immutable event types

Event Sourcing defines its envelope at the database schema level. The conventional columns are: aggregate_id (UUID), aggregate_type (domain boundary — "order", "user", "shipment"), sequence_number (monotonically increasing per aggregate), event_type (reverse-DNS or dot-notation name — "order.item.added"), event_version (integer schema version), payload (JSONB — the event data), metadata (JSONB — correlation IDs, actor IDs, causation IDs), and created_at.

The immutability rule is absolute: once an event is in the log, its payload schema cannot change, because other consumers may have already processed it. When a business requirement changes the shape of an event — new required fields, renamed fields, removed fields — introduce a new event type version: order.item.added.v2, not a mutated order.item.added. Write an applyEvent function that handles both versions:

function applyEvent(state: OrderState, eventType: string, version: number, payload: Record<string, unknown>): OrderState {
  switch (eventType) {
    case 'order.item.added':
      if (version === 1) {
        // v1 had sku + quantity only
        return { ...state, items: [...state.items, { sku: payload.sku as string, qty: payload.quantity as number, unitPrice: 0 }] };
      }
      if (version === 2) {
        // v2 added unitPrice — handle both
        return { ...state, items: [...state.items, { sku: payload.sku as string, qty: payload.quantity as number, unitPrice: payload.unit_price as number }] };
      }
      return state;

    case 'order.placed':
      return { ...state, placedAt: payload.placed_at as string, status: 'placed' };

    default:
      return state;  // unknown event type — forward-compatible: return unchanged state
  }
}

Replaying aggregate state must use ORDER BY sequence_number ASC, never ORDER BY created_at ASC. Wall-clock timestamps are unreliable across distributed systems: NTP corrections, clock drift, and DST transitions can cause two events written milliseconds apart from different nodes to sort in the wrong order by timestamp. The sequence_number is the canonical ordering key — it is enforced by the database and cannot drift.

Pattern 3 — Consumer Groups and Acknowledgment: Who Tracks Progress, and What Happens When a Consumer Dies

Durable message consumption requires tracking two things: which messages have been delivered to which consumer, and which have been processed successfully. The five technologies handle this tracking at different levels — some in the broker, some in the application, some not at all.

Redis Streams — explicit PEL, XACK, and XAUTOCLAIM

Redis Streams consumer groups are the most explicit: the broker (Redis) tracks delivery and acknowledgment per-consumer. When your MCP server calls XREADGROUP GROUP mygroup consumer1 COUNT 10 BLOCK 2000 STREAMS stream >, Redis delivers up to 10 undelivered entries, records them in consumer1's PEL, and blocks for 2 seconds if no new entries are available. After processing each entry, call XACK to remove it from the PEL.

The critical operational concern is consumer lag — the combination of PEL entries (delivered but not acknowledged) and new entries (not yet delivered). XINFO GROUPS stream returns the lag field per group (Redis 7.0+), which is the count of entries in the stream that the group has not yet delivered to any consumer. A high lag means consumers are falling behind; a high pending-count means consumers are receiving entries but not acknowledging them, which usually means a crash.

// Monitor group health — run this on a schedule or from an MCP health resource
async function getGroupHealth(streamName: string): Promise<GroupHealth[]> {
  const groups = await redis.xinfo('GROUPS', streamName) as unknown[][];
  return groups.map(g => {
    const m: Record<string, unknown> = {};
    const arr = g as unknown[];
    for (let i = 0; i < arr.length - 1; i += 2) m[String(arr[i])] = arr[i + 1];
    return {
      name:     m['name'] as string,
      pending:  m['pending-count'] as number,   // entries in PEL (delivered, not acked)
      lag:      m['lag'] as number,              // entries not yet delivered to any consumer
      consumers: m['consumers'] as number,
    };
  });
}

// Recovery: transfer PEL entries idle > 60s to an active consumer
async function recoverStuckMessages(activeConsumer: string): Promise<number> {
  const [, claimed] = await redis.xautoclaim(
    STREAM_NAME, CONSUMER_GROUP, activeConsumer,
    60_000,   // min idle time in milliseconds
    '0-0',    // start from beginning of PEL
    'COUNT', 100
  ) as [string, Array<[string, string[]]>, string[]];
  return claimed.length;
}

Dapr pub/sub — HTTP response controls delivery; sidecar owns retry policy

Dapr's acknowledgment model is HTTP-response-based. Your subscription endpoint receives a POST with the CloudEvent, does its processing, and returns a JSON body with a status field. The Dapr sidecar interprets this response:

The sidecar does not expose a consumer group lag metric directly. Monitor Dapr via its sidecar metrics endpoint (/metrics in Prometheus format) which exports per-topic message counts and retry counts. For operational awareness, log the cloudEvent.id and processing duration in your handler — correlation IDs in Dapr events flow via the metadata field when chained across multiple pub/sub operations.

await daprServer.pubsub.subscribe(PUBSUB_NAME, 'order-events', async (cloudEvent) => {
  const startMs = Date.now();
  try {
    await processOrderEvent(cloudEvent.data);
    console.log({ event: 'processed', id: cloudEvent.id, ms: Date.now() - startMs });
    return { status: 'SUCCESS' };
  } catch (err) {
    if (err instanceof TransientError) {
      console.warn({ event: 'retry', id: cloudEvent.id, reason: err.message });
      return { status: 'RETRY' };
    }
    // Permanent failure — log before dropping so you can replay manually
    console.error({ event: 'drop', id: cloudEvent.id, error: err });
    return { status: 'DROP' };
  }
});

AWS EventBridge — no consumer-side acknowledgment; DLQ at target

EventBridge has no consumer group concept and no acknowledgment mechanism. When EventBridge routes an event to a target (a Lambda function, an SQS queue, a Step Functions state machine), it tries the invocation and retries on failure according to the target's retry policy. The event source and detail-type determine which rule fires, and the rule determines which targets receive it — your code has no role in this routing.

For failed deliveries, configure a Dead Letter Queue on the rule target: EventBridge sends events that exhaust all retries to the DLQ SQS queue. Without a DLQ, failed events are silently lost.

For MCP tools that need to confirm a downstream effect of a PutEvents call, the standard pattern is to write to a shared state store before publishing, have the downstream consumer update that state after processing, and poll the shared state from the MCP tool. This converts the fire-and-forget semantics into an eventually-consistent check-and-poll pattern.

Event Sourcing — projector checkpoints for at-least-once projection

Event Sourcing CQRS projectors read from the events table and write denormalized read-model tables. They are long-running processes (or scheduled tasks) that must track their own progress — the projector_checkpoints table stores the last processed id from the events table for each named projector.

-- Schema
CREATE TABLE projector_checkpoints (
  projector_name TEXT   PRIMARY KEY,
  last_event_id  BIGINT NOT NULL DEFAULT 0
);

-- Projector loop — reads forward from checkpoint
async function runProjector(name: string, batchSize: number = 500): Promise<void> {
  // Upsert initial checkpoint
  const { rows: [cp] } = await db.query(
    `INSERT INTO projector_checkpoints (projector_name, last_event_id)
     VALUES ($1, 0)
     ON CONFLICT (projector_name) DO UPDATE SET projector_name = EXCLUDED.projector_name
     RETURNING last_event_id`,
    [name]
  );
  let lastId = BigInt(cp.last_event_id);

  while (true) {
    const { rows } = await db.query(
      `SELECT id, aggregate_id, aggregate_type, event_type, event_version, payload
       FROM events WHERE id > $1 ORDER BY id ASC LIMIT $2`,
      [lastId, batchSize]
    );

    if (rows.length === 0) {
      await sleep(500);  // use LISTEN/NOTIFY in production to avoid polling
      continue;
    }

    for (const event of rows) {
      await projectEvent(event);  // must be idempotent — same event may arrive twice
      lastId = BigInt(event.id);
    }

    await db.query(
      `UPDATE projector_checkpoints SET last_event_id = $1 WHERE projector_name = $2`,
      [lastId, name]
    );
  }
}

Projections must be idempotent — if a projector crashes after processing some events but before committing the checkpoint, those events will be replayed on restart. Use ON CONFLICT DO UPDATE (upsert) for row-level idempotency in the read-model table. Never replay events in an MCP tool handler on every query — that defeats the purpose of CQRS; query handlers should read the read-model tables, not the events table.

For real-time projector wake-up (to avoid polling), use PostgreSQL's LISTEN/NOTIFY: have the event append function fire a NOTIFY events_appended, '' and the projector LISTEN events_appended — the projector wakes up within milliseconds of a new event rather than sleeping for 500ms between polls. This reduces projector lag from ~500ms to ~10ms without adding a separate message broker.

CloudEvents — HTTP response code determines retry

When a broker delivers a CloudEvent to your HTTP endpoint, your response status code determines retry behavior (for brokers that implement the CloudEvents HTTP binding delivery guidance):

The spec guidance says to return 200 for event types you do not recognize — returning 4xx for unknown types would cause brokers to stop sending any events to your endpoint if new event types are introduced upstream. Handle forward-compatibility explicitly:

app.post('/events', async (req, res) => {
  const event = HTTP.toEvent({ headers: req.headers, body: req.body });

  // Return 200 for unknown types (do not punish the broker for new event types)
  if (!KNOWN_EVENT_TYPES.has(event.type)) {
    res.status(200).json({ status: 'ignored', type: event.type });
    return;
  }

  try {
    await handleEvent(event);
    res.status(200).json({ status: 'ok', id: event.id });
  } catch (err) {
    if (err instanceof PermanentError) {
      res.status(400).json({ status: 'rejected', reason: err.message });
    } else {
      res.status(500).json({ status: 'error', retry: true });
    }
  }
});

Health Probe Comparison

Each technology requires a different health probe strategy at MCP server startup. A common mistake is checking only that the SDK client was constructed, not that the underlying infrastructure is reachable and healthy.

Technology Health probe command Healthy signal What it validates
Dapr GET /v1.0/healthz/outbound on sidecar HTTP 204 Sidecar process alive AND all configured component connections (Kafka, Redis, etc.) healthy. Use outbound variant — not /v1.0/healthz which only checks sidecar process.
AWS EventBridge DescribeEventBusCommand({ Name: busName }) No exception thrown; response has Name and Arn Event bus exists and IAM credentials have describe permission. Does not verify PutEvents permission — add a dry-run TestEventPattern call if needed.
CloudEvents Construct new CloudEvent({ ... }) with required fields No validation error from constructor SDK installed and required attribute validation passes. For broker connectivity, probe the broker's health endpoint separately (EventBridge, Knative, etc.).
Redis Streams XINFO STREAM stream + XINFO GROUPS stream No NOGROUP or WRONGTYPE error; lag ≤ threshold Stream exists, consumer group exists, Redis connection alive. Report per-group lag from XINFO GROUPS — high lag signals consumer falling behind.
Event Sourcing SELECT last_event_id FROM projector_checkpoints WHERE projector_name = $1 Row exists; compare checkpoint delta to latest MAX(id) from events Database reachable, projector table exists, projector lag (events table max id minus checkpoint id) is within acceptable bounds.

For Dapr in particular, never use /v1.0/healthz (200 = sidecar process alive) as your production startup probe — a sidecar that is alive but cannot reach its Kafka broker or Redis state store returns 200 from /healthz and 500 from /healthz/outbound. An MCP server that starts accepting tool calls with a degraded sidecar will fail every pub/sub or state operation at runtime.

Snapshots in Event Sourcing — when to trust them and when to discard

Snapshots are an optimization that cuts replay time from O(all events) to O(events since last snapshot). An aggregate with 10,000 events and a snapshot taken at event 9,800 replays only the 200 most recent events. But snapshots introduce a safety concern: the snapshot stores the aggregate's computed state, which means it was produced by a specific version of the applyEvent function. If you change the aggregate's event handling logic (say, adding a new field to the OrderState type), old snapshots reflect the old shape.

The snapshot_schema_version column is the safety valve. Increment it whenever you change the aggregate's state shape in a way that makes old snapshots incompatible. On load, only trust snapshots with a matching snapshot_schema_version — discard and do a full replay otherwise:

const CURRENT_SCHEMA_VERSION = 4;  // increment when applyEvent shape changes

// Load aggregate — snapshots checked for version compatibility
const snapResult = await db.query(
  `SELECT sequence_number, state
   FROM aggregate_snapshots
   WHERE aggregate_id = $1
     AND snapshot_schema_version = $2  // version filter — discard old-schema snapshots
   ORDER BY sequence_number DESC
   LIMIT 1`,
  [aggregateId, CURRENT_SCHEMA_VERSION]
);

let replayFrom = 0;
let state = initialState();
if (snapResult.rows.length > 0) {
  state        = snapResult.rows[0].state;
  replayFrom   = snapResult.rows[0].sequence_number;
  // Replay only events after the snapshot — O(events since snapshot), not O(all events)
}

const events = await db.query(
  `SELECT sequence_number, event_type, event_version, payload
   FROM events WHERE aggregate_id = $1 AND sequence_number > $2 ORDER BY sequence_number ASC`,
  [aggregateId, replayFrom]
);

Take new snapshots proactively: after replaying more than SNAPSHOT_THRESHOLD events (typically 200), write a new snapshot at the current sequence number so future loads don't need to replay the same events again. Use ON CONFLICT DO NOTHING in the snapshot insert to handle concurrent replay attempts gracefully.

Technology Selection Guide

Eight common MCP server event-driven use cases and which technology fits each:

Use case Best fit Why
Publish domain events to multiple AWS services (Lambda, Step Functions, SQS) AWS EventBridge Native AWS integration; content-based routing via event patterns; no broker to operate
Infrastructure-agnostic pub/sub that swaps Redis for Kafka in production Dapr Sidecar isolates your code from broker SDK; swap component YAML without code changes
Publish events to multiple brokers (Knative + EventBridge + internal) from one publisher CloudEvents CNCF standard envelope enables broker-agnostic routing; structured mode survives proxy hops
Durable task queue with dead-consumer recovery and per-group lag monitoring Redis Streams PEL + XAUTOCLAIM provides explicit delivery tracking; XINFO GROUPS exposes per-group lag
Audit trail with full replay of state changes for compliance or debugging Event Sourcing Immutable event log is the only pattern that enables exact replay to any historical point in time
Multiple read views of the same domain data (summary table + search index + analytics) Event Sourcing + CQRS Multiple projectors consume the same event stream and build separate read-model tables independently
Real-time streaming pipeline where consumers can catch up after downtime Redis Streams Persistent log (unlike Redis pub/sub) + consumer group offset tracking enables catch-up replay
Cross-service event interoperability between Knative, EventBridge, and Dapr CloudEvents All three brokers support CloudEvents natively or via adapters; standard envelope enables transparent routing across all three

Common Failure Modes Across All Five

# Failure mode Technology Root cause Fix
1 Silent event loss on partial PutEvents failure EventBridge Checking HTTP 200 without inspecting FailedEntryCount Always check response.FailedEntryCount > 0 and inspect failed entry ErrorCode
2 CloudEvents attributes stripped in transit CloudEvents (binary mode) HTTP proxy strips ce-* headers; receiver gets raw data body with no event context Use structured mode (HTTP.structured(event)) when traversing untrusted proxies
3 OOM from unbounded XREAD Redis Streams XREAD COUNT 0 or omitted COUNT on a stream with millions of entries Always pass explicit COUNT 100-500 and use cursor loops
4 Stuck messages in PEL from dead consumers Redis Streams Consumer crashes after XREADGROUP but before XACK; entries stay in dead consumer's PEL Schedule periodic XAUTOCLAIM with min_idle_ms=60000
5 Dapr event.data is undefined Dapr Accessing cloudEvent.payload instead of cloudEvent.data Dapr wraps in CloudEvents; original payload is always in cloudEvent.data
6 Duplicate events from Dapr retry Dapr Handler throws before returning SUCCESS; Dapr retries; handler processes twice Use cloudEvent.id as dedup key in state store; check before processing
7 EventBridge Detail is double-serialized EventBridge Calling JSON.stringify(JSON.stringify(obj)) or passing pre-stringified value and stringifying again Call JSON.stringify(yourObject) exactly once in the Detail field
8 Aggregate state diverges between replays Event Sourcing ORDER BY created_at ASC instead of sequence_number ASC; clock drift reorders events Always replay via ORDER BY sequence_number ASC
9 Stale snapshot from old aggregate version Event Sourcing Snapshot loaded without checking snapshot_schema_version after an aggregate code change Filter snapshots by snapshot_schema_version = CURRENT_SCHEMA_VERSION; fall back to full replay
10 CloudEvents 0.3 attributes silently missing CloudEvents 0.3 event uses contenttype not datacontenttype; 1.0 consumer reads undefined Validate specversion === '1.0' first; return 422 for 0.3 events with explicit error
11 Projector lag grows unbounded Event Sourcing Projector uses 500ms sleep polling; high-write periods generate events faster than projector processes them Switch to PostgreSQL LISTEN/NOTIFY for sub-10ms wake-up on new events
12 Dapr sidecar healthy but components failed Dapr Using /v1.0/healthz (checks only sidecar process) instead of /v1.0/healthz/outbound Always probe /v1.0/healthz/outbound (204) at MCP server startup

Key Takeaways

Across all five event-driven technologies, three decisions repeat at every integration point:

  1. What does "published successfully" mean, and how do you detect partial failure? EventBridge requires you to check FailedEntryCount; Dapr requires you to return SUCCESS only after processing; Redis Streams requires XACK; Event Sourcing uses a constraint violation to signal a write conflict; CloudEvents defers to the broker. Never assume HTTP 200 = delivered + processed.
  2. Which field holds your actual payload, and what type is it? Dapr wraps in CloudEvents (event.data); EventBridge uses Detail as a JSON string (not object); CloudEvents puts data in data; Redis Streams use flat strings that you must JSON.parse; Event Sourcing uses JSONB payload column. Type mismatches here produce silent data loss, not exceptions.
  3. What happens when a consumer dies mid-processing? Redis Streams requires you to run XAUTOCLAIM explicitly; Dapr handles retry via sidecar policy; EventBridge uses DLQ at target; Event Sourcing projectors resume from checkpoint; CloudEvents depends on broker retry semantics. Without an explicit dead-consumer recovery mechanism, at-least-once guarantees degrade to maybe-once.

AliveMCP monitors MCP server endpoints every 60 seconds. Event-driven MCP servers that consume from dead Dapr sidecars, EventBridge buses with misconfigured IAM, or Redis Streams with unchecked PEL accumulation are a major source of the dead endpoints we track in the public dashboard. The patterns above directly address the failure modes behind those outages.