Message Queues · 2026-07-11 · Message Queue arc

MCP Tools for Message Queues: Consumer Lag Signals, Dead-Letter Verification, and At-Least-Once Delivery Across RabbitMQ, SQS, Pub/Sub, Pulsar, and NATS

When you build your first MCP tool that monitors a message queue, three problems surface in a predictable sequence. You check the queue depth and report it to the AI client — not realizing that a single number doesn't tell you whether the problem is a growing backlog (producers outpacing consumers) or stuck in-flight messages (consumers holding messages but not completing processing). You report the dead-letter queue depth as a health signal — not realizing the dead-letter path may not be configured on the queue at all, making the zero you're reporting meaningless silence rather than health. You implement idempotent consumers because the queue says it delivers at-least-once — not realizing the mechanism that makes redelivery happen is completely different across platforms, and getting the mechanism wrong means either silent message loss or uncontrolled retry storms. By the second message queue platform you encounter, you recognize all three problems wearing a different API's uniform. This synthesis covers five platforms — RabbitMQ, AWS SQS, Google Pub/Sub, Apache Pulsar, and NATS JetStream — through the three structural patterns they all share, so you can implement them correctly before they surface as production failures in your MCP tools.

TL;DR

Five message queue platforms, three shared patterns. (1) Consumer lag is a two-number signal: every platform exposes a separate metric for backlog (messages waiting to be delivered) and in-flight (messages delivered but not yet acknowledged) — treating these as one number misses two distinct failure modes; backlog growing with zero in-flight = consumer starvation (nothing is receiving); in-flight high with stable backlog = consumer processing failure (messages received but not completed); RabbitMQ calls them messages_ready vs messages_unacknowledged; SQS calls them ApproximateNumberOfMessages vs ApproximateNumberOfMessagesNotVisible; Pub/Sub exposes num_undelivered_messages (backlog) and oldest_unacked_message_age (SLA time signal); Pulsar exposes msgBacklog and unackedMessages per subscription; NATS JetStream exposes num_pending (undelivered) and num_ack_pending (delivered but not acknowledged) on the consumer info object. (2) Dead-letter paths require configuration verification before depth means anything: DLQ depth is only a health signal if the dead-letter path is actually configured — on all five platforms, the DLQ does not exist by default; RabbitMQ: check x-dead-letter-exchange in queue arguments before querying DLQ depth (if absent, the zero you see is not silence, it is nothing); SQS: parse RedrivePolicy attribute before looking up the DLQ URL (messages failing before the DLQ exists are silently discarded); Pub/Sub: check deadLetterPolicy on the subscription before querying the dead letter topic (the DLT also requires separate IAM grants — misconfigured IAM causes dead-lettered messages to be dropped at the broker without appearing in the DLT); Pulsar: no native DLQ — dead-letter behavior requires consumer-side implementation (nack → maxDeliveries → publish to a dead-letter topic the consumer writes to); NATS JetStream: no native DLQ — dead-letter behavior requires a term() ack followed by consumer-side publish to a separate stream. (3) At-least-once delivery uses five different mechanisms, all requiring idempotent consumers: RabbitMQ uses acknowledgment-based delivery (basic.ack for success; basic.nack/basic.reject with requeue=false to dead-letter; prefetch_count controls in-flight buffer size; prefetch=0 is dangerous — one consumer pulls all messages into memory making the queue appear empty); SQS uses visibility timeout (the ReceiptHandle token is per-delivery, not per-message; ChangeMessageVisibility extends the timeout for long-running processing; the MessageId is the stable deduplication key across redeliveries, not the ReceiptHandle); Pub/Sub uses ack deadline (streaming pull auto-extends deadlines; synchronous pull requires explicit modifyAckDeadline; nack = immediately redeliver, not skip); Pulsar uses four ack modes via JetStream-style API (ack, nak with delay, term for permanent failure, in-progress/working for heartbeat) plus per-subscription maxDeliveries before DLQ-equivalent; NATS JetStream uses an ack_wait duration plus max_deliver count (pull consumers are preferred for MCP tools because they fetch on demand and provide natural backpressure — push consumers can trigger slow-consumer drops in NATS). Wire AliveMCP to a composite health endpoint that checks both the queue lag signal (both backlog and in-flight numbers) and the dead-letter depth — not a single queue depth check — so alert conditions match actual failure modes rather than a single number that looks fine while the system silently processes nothing.

Pattern 1: Consumer lag is a two-number signal, not one

The most common mistake in message queue MCP tooling is reporting a single "queue depth" number. Every message queue platform separates message state into at least two categories: messages waiting to be delivered (backlog) and messages delivered but not yet acknowledged (in-flight). These two numbers diagnose completely different failure modes, and collapsing them into a single total hides both.

Consider the following failure sequence: an order processing service restarts after a crash. Its consumers reconnect successfully. The queue shows a total depth of 10,000 messages. An MCP tool reporting just the total gives the AI client no information about whether the system is processing (10,000 in-flight, actively being worked) or frozen (10,000 backlog, nothing receiving them). The actions required are different: a growing backlog with zero in-flight needs consumer investigation; a growing in-flight with stable backlog needs processing timeout and retry investigation.

RabbitMQ: messages_ready vs messages_unacknowledged

RabbitMQ's Management API makes this distinction explicit with two fields on every queue object. messages_ready counts messages in the queue waiting for delivery to a consumer — this is the backlog. messages_unacknowledged counts messages that have been sent to a consumer channel but not yet acknowledged with basic.ack — these are in-flight. The sum messages is the total.

A spike in messages_unacknowledged with stable messages_ready is a strong signal that consumers have stopped making progress — they are holding messages but not completing them. This happens when a consumer is waiting on a slow downstream dependency (a database that became slow, an external API that is hanging), or when a consumer is silently failing without nacking the messages.

The consumers field is a distinct third signal: messages_ready > 0 with consumers === 0 is consumer starvation — the highest-priority alert, indicating nothing is connected to process the queue.

// RabbitMQ: two-number lag check
const q = await rabbitClient.getQueue(vhost, queueName);
return {
  backlog: q.messages_ready,           // waiting for delivery
  inflight: q.messages_unacknowledged, // delivered, unacknowledged
  total: q.messages,                   // sum (less diagnostic)
  consumers: q.consumers,
  state: q.state,                      // 'flow' = broker throttling producers
  // Derived signals:
  starvation: q.consumers === 0 && q.messages_ready > 0,
  stuck_inflight: q.messages_unacknowledged > (q.consumers * 100) && q.message_stats?.ack_details?.rate === 0,
};

SQS: three approximate counts

AWS SQS extends the two-number model with a third: ApproximateNumberOfMessages (backlog — messages ready to be received), ApproximateNumberOfMessagesNotVisible (in-flight — received but visibility timeout not expired), and ApproximateNumberOfMessagesDelayed (delay-queued — not yet available due to DelaySeconds). All three are approximate because SQS is a distributed system with eventual consistency on these counters.

SQS does not expose a consumer count — consumers are Lambda functions or EC2 processes that SQS does not track. Consumer starvation must be inferred: ApproximateNumberOfMessages growing while ApproximateNumberOfMessagesNotVisible stays near zero indicates nothing is polling the queue. The most valuable SLA metric SQS provides is ApproximateAgeOfOldestMessage from CloudWatch — how old is the oldest unprocessed message? This transforms a count into a time-based SLA signal: "no message should wait more than 5 minutes" is a better alert than "queue depth > 1000".

Google Pub/Sub: count vs age

Google Pub/Sub exposes backlog through Cloud Monitoring metrics, not a direct admin API call. The two key metrics per subscription are subscription/num_undelivered_messages (total unacknowledged messages, across both waiting and in-flight) and subscription/oldest_unacked_message_age (seconds since the oldest unacknowledged message was published). The age metric is Pub/Sub's equivalent of SQS's ApproximateAgeOfOldestMessage — it is more actionable than the count alone because it answers whether the system is falling behind relative to its SLA rather than just how many messages are waiting.

Pulsar: msgBacklog, unackedMessages, and the redeliver rate

Apache Pulsar's Admin REST API provides per-subscription health data in the topic stats endpoint. msgBacklog counts messages in the subscription's backlog (not yet delivered to any consumer). unackedMessages counts messages delivered but not yet acknowledged. Pulsar adds a third signal that RabbitMQ and SQS lack: msgRateRedeliver — the rate at which messages are being redelivered because ack deadlines expired or consumers explicitly nacked them. A subscription with zero msgRateOut (no delivery to consumers) but positive msgRateRedeliver is thrashing — receiving messages, failing to process them, and redelivering in a loop with no forward progress.

NATS JetStream: num_pending vs num_ack_pending

NATS JetStream separates stream state from consumer state. The stream reports total messages stored. Per-consumer, JetStream exposes num_pending (messages in the stream not yet delivered to this consumer) and num_ack_pending (messages delivered to this consumer that have not been acknowledged). The important distinction is that the stream's message count is not the consumer's backlog — the consumer's lag is measured by num_pending, not by the stream's total, because the stream may hold many more messages than this consumer has ever seen.

// NATS JetStream: consumer lag check
const info = await jsm.consumers.info(streamName, consumerName);
return {
  backlog: info.num_pending,      // messages not yet delivered to this consumer
  inflight: info.num_ack_pending, // delivered but not acknowledged
  delivered: info.delivered.consumer_seq,
  ack_floor: info.ack_floor.consumer_seq,
  // lag in sequences:
  seq_lag: info.delivered.consumer_seq - info.ack_floor.consumer_seq,
};

Cross-platform comparison table

Platform Backlog metric In-flight metric SLA age metric Consumer count Thrash signal
RabbitMQ messages_ready messages_unacknowledged None (compute from rate data) consumers field ack_rate = 0 with unacked > 0
SQS ApproxNumberOfMessages ApproxNumberOfMessagesNotVisible ApproxAgeOfOldestMessage (CW) Not exposed Msgs rising, NotVisible = 0
Pub/Sub num_undelivered_messages Included in above oldest_unacked_message_age Not exposed (Cloud Monitoring) Age rising, delivery rate 0
Pulsar msgBacklog unackedMessages None (compute from backlogSize) consumers[].availablePermits msgRateRedeliver > 10% of msgRateOut
NATS JS num_pending num_ack_pending None (compute from stream first_seq age) Pull consumer: implicit by fetch calls ack_floor stalled vs delivered.seq

Pattern 2: Dead-letter paths require configuration verification before depth means anything

The second most common mistake in message queue MCP tooling is reporting dead-letter queue depth without first verifying that the dead-letter path is configured. On all five platforms, no dead-letter destination exists by default. A DLQ depth of zero reported by an MCP tool that has not checked the configuration could mean: (a) nothing has failed — or (b) everything has failed and dead-lettered messages are being silently discarded because no one configured where they should go.

This is not a theoretical concern. Teams routinely configure the source queue correctly and forget to: bind a queue to the dead-letter exchange (RabbitMQ), create the DLQ before setting the redrive policy (SQS), grant the Pub/Sub service account publish permission on the dead letter topic (Pub/Sub), or implement the consumer-side dead-letter handler at all (Pulsar, NATS). In each case, the dead-letter depth reads zero while failed messages vanish.

RabbitMQ: two-step DLQ verification

In RabbitMQ, dead-letter exchange configuration is optional and per-queue. A message is dead-lettered when: it is nacked without requeue; its per-message or policy-level TTL expires; or the queue reaches x-max-length with x-overflow: "reject-publish-dlx". Dead-lettered messages are published to the exchange named in the queue's x-dead-letter-exchange argument.

The two-step verification: first, check that x-dead-letter-exchange is set in the queue's arguments object. If absent, dead-lettered messages are silently discarded. Second, use the bindings API to find which queues are actually bound to receive from that exchange. A common misconfiguration sets x-dead-letter-exchange but never binds any queue to the exchange — dead-lettered messages accumulate in the exchange with nowhere to go.

// RabbitMQ: DLQ verification before trusting depth
async function getDLQStatus(client, vhost, queueName) {
  const q = await client.getQueue(vhost, queueName);
  const dlxName = q.arguments?.['x-dead-letter-exchange'];

  if (!dlxName) {
    return { dlx_configured: false, warning: 'Dead-lettered messages are silently discarded' };
  }

  // Discover which queues receive dead-lettered messages from this exchange
  const bindings = await client.request(
    `/bindings/${encodeURIComponent(vhost)}/e/${encodeURIComponent(dlxName)}/q`
  );

  if (bindings.length === 0) {
    return { dlx_configured: true, dlx_name: dlxName, dlq_bound: false,
      warning: 'DLX configured but no queue bound — dead-lettered messages vanish' };
  }

  const dlqQueues = await Promise.all(
    bindings.map(b => client.getQueue(vhost, b.destination))
  );

  return {
    dlx_configured: true,
    dlx_name: dlxName,
    dlq_queues: dlqQueues.map(q => ({
      name: q.name,
      depth: q.messages_ready,
      // Check x-death header count on messages to understand retry history
    }))
  };
}

An additional subtlety: dead-lettered RabbitMQ messages carry an x-death header array. Each entry records the queue name, exchange, routing key, reason (rejected/expired/maxlen), and count (how many times this message died in this queue for this reason). The count field on the first entry is the effective retry count — a message with x-death[0].count > 5 indicates a persistent processing failure requiring investigation, distinct from a transient failure that dead-lettered once and recovered.

SQS: RedrivePolicy must exist and DLQ URL must be resolved

SQS implements dead-letter routing via the source queue's RedrivePolicy attribute, which is a JSON object containing the DLQ's ARN and the maxReceiveCount threshold. When a message's ApproximateReceiveCount reaches maxReceiveCount, SQS automatically moves it to the DLQ.

Three failure points to verify. First, RedrivePolicy may not be set at all — if absent, messages that fail maxReceiveCount times are simply discarded. Second, the DLQ is a separate SQS queue with its own URL; converting the ARN to a URL requires a GetQueueUrl API call with the queue name extracted from the ARN. Third, the DLQ must have its own message retention period configured correctly — the default 4-day retention means a DLQ that fills up during a weekend outage may lose messages by Monday without anyone noticing.

// SQS: DLQ verification
async function getSQSDLQStatus(queueUrl) {
  const attrs = await sqs.send(new GetQueueAttributesCommand({
    QueueUrl: queueUrl,
    AttributeNames: ['RedrivePolicy', 'QueueArn']
  }));

  const redriveRaw = attrs.Attributes?.RedrivePolicy;
  if (!redriveRaw) {
    return { dlq_configured: false, warning: 'No RedrivePolicy — failed messages discarded after N receives' };
  }

  const redrive = JSON.parse(redriveRaw);
  // ARN: arn:aws:sqs:{region}:{account}:{queue-name}
  const dlqName = redrive.deadLetterTargetArn.split(':').at(-1);
  const { QueueUrl: dlqUrl } = await sqs.send(new GetQueueUrlCommand({ QueueName: dlqName }));

  const dlqAttrs = await sqs.send(new GetQueueAttributesCommand({
    QueueUrl: dlqUrl,
    AttributeNames: ['ApproximateNumberOfMessages', 'MessageRetentionPeriod']
  }));

  const depth = parseInt(dlqAttrs.Attributes?.ApproximateNumberOfMessages ?? '0');
  const retentionSec = parseInt(dlqAttrs.Attributes?.MessageRetentionPeriod ?? '345600');

  return {
    dlq_configured: true,
    dlq_url: dlqUrl,
    max_receive_count: redrive.maxReceiveCount,
    dlq_depth: depth,
    dlq_retention_days: retentionSec / 86400,
    has_failures: depth > 0,
    retention_warning: retentionSec < 7 * 86400, // less than 7 days
  };
}

Google Pub/Sub: dead letter topic requires IAM grants at configuration time

Pub/Sub implements dead-letter delivery via a deadLetterPolicy on the subscription, pointing to a dead letter topic (DLT). When a message's delivery attempt count reaches maxDeliveryAttempts, Pub/Sub tries to publish it to the DLT. The phrase "tries to" is the key — if the Pub/Sub service account lacks the roles/pubsub.publisher role on the DLT, the publish silently fails and the message is dropped. This makes Pub/Sub's dead-letter configuration the most operationally treacherous of the five platforms: the DLT can appear correctly configured in the subscription metadata while silently failing to receive dead-lettered messages because of an IAM error.

// Pub/Sub: DLT verification including IAM check
async function getPubSubDLTStatus(subscriptionName) {
  const subscription = pubsub.subscription(subscriptionName);
  const [meta] = await subscription.getMetadata();

  if (!meta.deadLetterPolicy) {
    return { dlt_configured: false, warning: 'No deadLetterPolicy — messages failing maxDeliveryAttempts are dropped' };
  }

  const dltName = meta.deadLetterPolicy.deadLetterTopic;
  const maxAttempts = meta.deadLetterPolicy.maxDeliveryAttempts;

  // Verify DLT exists and has a subscription to drain it
  const dltTopic = pubsub.topic(dltName);
  const [dltSubs] = await dltTopic.getSubscriptions();

  return {
    dlt_configured: true,
    dlt_topic: dltName,
    max_delivery_attempts: maxAttempts,
    dlt_has_subscription: dltSubs.length > 0,
    dlt_warning: dltSubs.length === 0
      ? 'DLT has no subscription — dead-lettered messages accumulate with no consumer'
      : null,
    // NOTE: IAM validation requires checking the Pub/Sub SA has pubsub.publisher on dltTopic
    // If not, messages are silently dropped at the broker without appearing in DLT
    iam_note: 'Verify pubsub.googleapis.com service account has roles/pubsub.publisher on DLT'
  };
}

Pub/Sub's additional complication is subscription expiration. A subscription with expirationPolicy.ttl is automatically deleted after the TTL period (default: 31 days) if no subscriber connects. For batch workloads that poll infrequently, this means the DLT subscription could itself expire while a long gap in processing occurs, creating a window where dead-lettered messages go to a topic with no consumer.

Pulsar and NATS: no native DLQ — consumer-side implementation required

Apache Pulsar and NATS JetStream have no built-in dead-letter queue mechanism. The pattern for both is consumer-implemented: when a message fails maxDeliveries times (Pulsar) or max_deliver times (NATS), the consumer must catch this case and publish the message to a separately-created dead-letter stream or topic.

For Pulsar, the consumer receives the delivery count via the message's deliveryCount property. When deliveryCount >= maxDeliveries - 1, the consumer should: process the message anyway (last attempt), and if it still fails, publish it to the DLQ topic and then ack the original (not nack — nacking would trigger another delivery cycle with no DLQ configured to catch it). Using a separate DLQPolicy object on the consumer config is the cleaner approach: Pulsar's client library handles the DLQ routing automatically when the consumer's native retry mechanism is used.

For NATS JetStream, when a message exceeds max_deliver, the consumer automatically sets a terminal NAK on it — but unless there is an explicit filterSubject stream capturing those terminal messages, they are simply not redelivered. The consumer-side pattern is: in the message processing function, when processing fails permanently (not a transient error), call msg.term() to signal permanent failure, then explicitly publish the message payload to a separately-configured dead-letter stream before calling term().

// NATS JetStream: manual dead-letter pattern
async function processWithDLQ(streamName, consumerName, dlqSubject) {
  const consumer = await js.consumers.get(streamName, consumerName);
  const messages = await consumer.fetch({ max_messages: 10, expires: 5000 });

  for await (const msg of messages) {
    const deliveryCount = msg.info.redeliveryCount ?? 0;
    const isPermanentFailure = deliveryCount >= MAX_RETRIES;

    try {
      await processMessage(jc.decode(msg.data));
      msg.ack();
    } catch (err) {
      if (isPermanentFailure) {
        // Publish to dead-letter stream BEFORE terming the message
        await js.publish(dlqSubject, msg.data, {
          headers: (() => {
            const h = headers();
            h.set('Nats-Msg-Id', `dlq-${msg.subject}-${msg.seq}`);
            h.set('X-Original-Subject', msg.subject);
            h.set('X-Delivery-Count', String(deliveryCount));
            h.set('X-Failure-Reason', err.message);
            return h;
          })()
        });
        msg.term(); // signals permanent failure, prevents redelivery
      } else {
        msg.nak(30000); // retry after 30 seconds
      }
    }
  }
}

Pattern 3: At-least-once delivery uses five different mechanisms — all requiring idempotent consumers

Every platform in this arc guarantees at-least-once message delivery. The guarantee means the same message may be delivered more than once — from a duplicate publish, from a redelivery after consumer failure, or from timeout expiry. Idempotent consumers are not optional. What varies across platforms is the mechanism that enforces the guarantee and the consumer-side API that controls redelivery behavior.

Getting the mechanism wrong has asymmetric failure modes: too-permissive implementations cause unbounded retry storms (every failed message retried forever with no DLQ to catch permanent failures); too-restrictive implementations cause silent message loss (messages disappear from the queue without being successfully processed).

RabbitMQ: acknowledgment state and prefetch control

RabbitMQ's at-least-once guarantee is implemented by the consumer channel's acknowledgment state. A message that has been delivered to a consumer but not acknowledged will be redelivered if the channel or connection closes. The consumer must call basic.ack with the delivery tag only after successful processing. Calling basic.nack with requeue=true puts the message back at the head of the queue for immediate redelivery. Calling basic.nack with requeue=false dead-letters the message (if DLX configured) or discards it.

The most dangerous RabbitMQ configuration for MCP tools is prefetch_count=0 (unlimited). With unlimited prefetch, a consumer pulls every ready message from the queue into its channel's in-flight buffer instantly. The queue appears empty. Other consumers receive nothing. All messages are now in-flight in one process's memory. If that process crashes, all those messages become redelivered simultaneously — which can cause a thundering-herd retry storm against downstream dependencies that are already unhealthy. An MCP tool should flag any consumer with prefetch_count === 0 in the consumer details API.

SQS: ReceiptHandle lifecycle and visibility timeout heartbeating

SQS's at-least-once mechanism is the visibility timeout. When a consumer receives a message, it becomes invisible to other consumers for the VisibilityTimeout duration. The consumer must call DeleteMessage with the ReceiptHandle before the timeout expires. If the consumer fails to delete before the timeout, the message becomes visible again and is redelivered to the next caller of ReceiveMessage.

Two critical properties of the ReceiptHandle: it is a per-delivery token, not a per-message identifier. The same message, if redelivered, gets a new ReceiptHandle. Using a ReceiptHandle from a previous delivery in DeleteMessage returns ReceiptHandleIsInvalid. The stable deduplication key for idempotency is the MessageId, which is assigned at send time and does not change across redeliveries.

For long-running MCP tool processing (anything taking more than 2/3 of the queue's visibility timeout), implement a heartbeat that calls ChangeMessageVisibility to extend the timeout before it expires:

// SQS: visibility timeout heartbeat for long-running processing
async function processWithHeartbeat(queueUrl, message, processor, visibilitySeconds = 300) {
  let heartbeat;
  try {
    // Extend visibility every 4 minutes (before 5-minute timeout)
    heartbeat = setInterval(() => {
      sqs.send(new ChangeMessageVisibilityCommand({
        QueueUrl: queueUrl,
        ReceiptHandle: message.ReceiptHandle,
        VisibilityTimeout: visibilitySeconds
      })).catch(err => console.error('Heartbeat failed:', err));
    }, 4 * 60 * 1000);

    await processor(message.Body, message.MessageId); // MessageId = stable dedup key
    await sqs.send(new DeleteMessageCommand({
      QueueUrl: queueUrl,
      ReceiptHandle: message.ReceiptHandle
    }));
  } finally {
    clearInterval(heartbeat);
  }
}

Pub/Sub: ack deadline and nack semantics

Pub/Sub's ack deadline is the per-message timeout equivalent to SQS's visibility timeout. For streaming pull (the preferred mode for continuous consumers), the client library automatically sends modifyAckDeadline keep-alive messages as long as the stream is open — the deadline is auto-extended while the subscription object is active and the message is not yet acked. For synchronous pull, the consumer must explicitly call modifyAckDeadline for long-running processing.

Pub/Sub's nack behavior differs from RabbitMQ and SQS in a subtle way: nacking a Pub/Sub message (message.nack()) causes immediate redelivery, not dead-lettering. The message goes back to the subscription and is delivered again, typically within seconds. This means nacking is appropriate for transient failures (network blip, dependency timeout) but will cause a retry storm for permanent failures (bad message schema, invalid payload) unless the consumer also checks the delivery attempt count against maxDeliveryAttempts and lets the dead-letter policy handle termination.

Pulsar: four ack modes with distinct semantics

Pulsar provides the most expressive consumer acknowledgment API of the five platforms, with four distinct modes:

For MCP tools using Pulsar, pull consumers (not push) are the correct choice. Push consumers in Pulsar are long-lived streaming connections that receive messages as the broker pushes them. In a stateless MCP server context where the server may restart frequently, pull consumers that fetch on-demand are more resilient: they don't lose pending deliveries on restart, and they provide natural backpressure.

NATS JetStream: ack modes and the pull consumer advantage

NATS JetStream mirrors Pulsar's ack mode design with similar names and semantics: msg.ack() (success), msg.nak(delay?) (transient failure with optional delay), msg.term() (permanent failure), and msg.working() (in-progress heartbeat). The ack_wait consumer configuration determines how long the server waits for an ack before redelivering — equivalent to Pub/Sub's ack deadline and SQS's visibility timeout.

For NATS, pull consumers are especially important for MCP tools. Push consumers (created with a push subject configured) deliver messages to a subscriber continuously without the consumer pulling. In NATS, if a push consumer receives messages faster than it can process them, NATS flags it as a slow consumer and can disconnect it or drop messages — a dangerous failure mode that manifests as silent message loss rather than a clear error. Pull consumers provide natural flow control: the consumer fetches only as many messages as it can handle at once.

Platform At-least-once mechanism Success signal Transient failure Permanent failure Heartbeat
RabbitMQ Channel ack state basic.ack(deliveryTag) basic.nack(tag, requeue=true) basic.nack(tag, requeue=false) Prefetch + slow processing
SQS Visibility timeout DeleteMessage(ReceiptHandle) Let timeout expire (redelivery) or ChangeMessageVisibility(0) DLQ via maxReceiveCount ChangeMessageVisibility(N) loop
Pub/Sub Ack deadline message.ack() message.nack() (immediate redeliver) DLT via maxDeliveryAttempts modifyAckDeadline (auto in streaming pull)
Pulsar Ack + redelivery policy message.ack() message.nack() with delay message.term() → DLQ topic message.working()
NATS JS ack_wait + max_deliver msg.ack() msg.nak(delayMs) msg.term() → manual DLQ publish msg.working()

Composite health endpoint: what to register with AliveMCP

A queue health probe that reports only a single depth number fails at its primary job: distinguishing between a system processing nothing (consumer starvation — critical) and a system processing normally with a transient backlog (warning) and a system producing faster than consuming (scale-out signal). The composite endpoint below implements the two-number check and DLQ verification for each platform:

// Composite queue health: checks both lag signal types and DLQ
app.get('/health/queue/:platform', async (req, res) => {
  const { platform } = req.params;
  try {
    let result;

    if (platform === 'rabbitmq') {
      const q = await rabbitClient.getQueue('/', process.env.QUEUE_NAME);
      const dlq = await getDLQStatus(rabbitClient, '/', process.env.QUEUE_NAME);
      result = {
        status: (q.consumers === 0 && q.messages_ready > 0) ? 'unhealthy' : 'healthy',
        backlog: q.messages_ready,
        inflight: q.messages_unacknowledged,
        consumers: q.consumers,
        state: q.state,
        dlq,
      };

    } else if (platform === 'sqs') {
      const health = await getQueueHealth(process.env.SQS_QUEUE_URL);
      const dlq = await getSQSDLQStatus(process.env.SQS_QUEUE_URL);
      result = {
        status: (health.messages_ready > 1000 && health.messages_inflight === 0)
          ? 'unhealthy' : 'healthy',
        ...health,
        dlq,
      };

    } else if (platform === 'nats') {
      const info = await jsm.consumers.info(
        process.env.NATS_STREAM, process.env.NATS_CONSUMER
      );
      result = {
        status: info.num_pending > 0 && info.num_ack_pending === 0
          ? 'stalled' : 'healthy',
        backlog: info.num_pending,
        inflight: info.num_ack_pending,
      };
    }

    const statusCode = result.status === 'unhealthy' || result.status === 'stalled' ? 503 : 200;
    res.status(statusCode).json(result);
  } catch (err) {
    res.status(503).json({ status: 'unreachable', error: err.message });
  }
});

Register /health/queue/{platform} with AliveMCP. Set the probe interval to 60 seconds — queue backlogs that grow silently for 10 minutes are often noticed by users before monitoring catches them. The composite endpoint captures the three failure modes that matter: consumer starvation (backlog growing, in-flight zero, consumers zero), processing failure (in-flight growing, ack rate zero), and dead-letter accumulation (DLQ depth nonzero).

For Pub/Sub and Pulsar, where the health data lives in platform monitoring systems rather than direct API calls, expose the health check as a separate Cloud Function or sidecar service that queries the monitoring API and exposes a simple HTTP/200 or /503 for AliveMCP to probe. This keeps the probe logic close to the queue consumer while giving AliveMCP a stable URL to check independently of your consumer process uptime.

Platform selection guide for MCP tool contexts

The five platforms are not interchangeable. The right choice for an MCP server context depends on deployment environment and operational requirements:

Three-point implementation checklist for every message queue MCP integration

  1. Report both lag numbers, not one. Add both backlog and in-flight counts to every queue health response. Derive the failure mode classification (starvation vs stuck vs processing normally with backlog) in the tool response so the AI client receives a diagnostic label, not just raw numbers.
  2. Verify dead-letter path before reporting DLQ depth. On every queue health check, confirm that the dead-letter path is configured (exchange argument, redrive policy, dead letter topic, consumer-side handler) before reporting DLQ depth. Report the configuration status alongside the depth so a zero-depth DLQ with no configuration is visibly different from a zero-depth DLQ with a healthy configuration.
  3. Implement the platform-appropriate completion signal. Use the platform's designed completion signal: RabbitMQ's basic.ack with the delivery tag; SQS's DeleteMessage with the ReceiptHandle before visibility timeout; Pub/Sub's message.ack() or streaming pull auto-extension; Pulsar's message.ack() vs nack() vs term() based on failure type; NATS's msg.ack() vs msg.nak(delay) vs msg.term(). Register the composite health endpoint URL with AliveMCP so consumer failures are detected before users notice message processing delays.

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free