Guide · AWS SNS
SNS Fan-Out to SQS for MCP Servers — Parallel Processing, Raw Message Delivery, Cross-Account
The SNS fan-out pattern decouples a single publish event from multiple independent downstream consumers by routing through a set of SQS queues. Without fan-out, adding a second consumer to a probe result pipeline requires either modifying the publisher to write to two queues, or daisy-chaining consumers in series — both increase coupling. With SNS fan-out, the MCP monitoring probe publishes one event to an SNS topic, and SNS independently delivers it to every subscribed SQS queue: the alerting pipeline, the analytics aggregator, the audit log writer, and the S3 archiver all receive the event in parallel, at their own processing rate, with independent failure isolation. If the analytics queue falls behind or the audit consumer crashes, neither affects the alerting queue.
TL;DR
Create an SNS topic, subscribe multiple SQS queues to it. Each SQS queue's resource policy must include an aws:SourceArn-conditioned sns:SendMessage allow statement. Enable RawMessageDelivery: true per subscription if consumers should receive the original JSON body directly (skip SNS envelope JSON wrapping). Fan-out is synchronous at the SNS level — all deliveries are attempted in parallel before the Publish API call returns. Delivery failures to individual queues do not fail the publish call; SNS retries each queue subscription independently.
Fan-out topology
A standard fan-out topology for an MCP monitoring pipeline:
┌──────────────────────────┐
│ MCP Probe Worker │
│ (Lambda / ECS task) │
└──────────┬───────────────-─┘
│ SNS Publish
▼
┌──────────────────────────┐
│ SNS Topic │
│ mcp-probe-results │
└─────────┬────────────────-─┘
┌──────────┬─────────┼─────────┬──────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
SQS: SQS: SQS: SQS: SQS:
alerting analytics audit s3-archiver dashboard
(filter: (all) (all) (all) (filter:
unhealthy) latencyMs
>2000)
Each queue is a separate subscription with its own retry policy, DLQ, consumer, and optionally its own filter policy. The alerting queue might only receive unhealthy events via a filter policy; the analytics queue receives all events. A failure in the s3-archiver consumer (e.g., S3 bucket permissions error) does not affect the alerting queue — they are completely independent pipelines.
SQS queue resource policy requirement
Every SQS queue that receives SNS deliveries must have an access policy explicitly granting sqs:SendMessage to the SNS topic principal. Without this policy, SNS cannot write to the queue and silently discards the message (no error on the Publish call — the delivery simply fails and enters SNS retry/DLQ logic).
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "sns.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:123456789012:mcp-alerting-queue",
"Condition": {
"ArnEquals": {
"aws:SourceArn": "arn:aws:sns:us-east-1:123456789012:mcp-probe-results"
}
}
}]
}
Always scope with aws:SourceArn. A policy that grants sqs:SendMessage to sns.amazonaws.com without a source ARN condition allows any SNS topic in any account to write to your queue — a confused deputy vulnerability. The ArnEquals condition locks permission to your specific topic only.
Raw message delivery vs SNS envelope
By default, SNS wraps the original message in a JSON envelope before writing to the SQS queue. The SQS consumer must parse the envelope to reach the original body. With RawMessageDelivery: true, the original message body is written directly — the SQS consumer receives exactly what the publisher sent.
| Property | Default (envelope) | RawMessageDelivery: true |
|---|---|---|
| SQS message body content | SNS envelope JSON (Type, MessageId, TopicArn, Message as string, Timestamp, Signature...) | Original message body exactly as published |
| Consumer code complexity | Must parse envelope; original body is a double-encoded JSON string (JSON.parse(msg.Message)) | Parse original body directly (JSON.parse(msg.Body)) |
| Message attributes available | Embedded in envelope MessageAttributes field | Available via SQS message attributes on the received message |
| Use case | When consumer needs SNS metadata (MessageId, Signature for verification) | When consumer only needs the payload and is purpose-built for this topic |
// Without raw delivery — consumer must unwrap SNS envelope
const sqsMessage = await receiveMessage(queueUrl);
const snsEnvelope = JSON.parse(sqsMessage.Body);
const probeResult = JSON.parse(snsEnvelope.Message); // double-parse
// With raw delivery — consumer receives original JSON directly
const sqsMessage = await receiveMessage(queueUrl);
const probeResult = JSON.parse(sqsMessage.Body); // single-parse
CDK: fan-out with multiple queue subscribers
import * as sns from "aws-cdk-lib/aws-sns";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions";
import { Duration } from "aws-cdk-lib";
const probeResultsTopic = new sns.Topic(this, "ProbeResultsTopic", {
topicName: "mcp-probe-results",
});
// Queue 1: alerting — only unhealthy/degraded events
const alertingQueue = new sqs.Queue(this, "AlertingQueue", {
visibilityTimeout: Duration.seconds(30),
deadLetterQueue: { queue: alertingDlq, maxReceiveCount: 3 },
});
probeResultsTopic.addSubscription(
new subscriptions.SqsSubscription(alertingQueue, {
rawMessageDelivery: true,
filterPolicy: {
status: sns.SubscriptionFilter.stringFilter({
allowlist: ["degraded", "unhealthy"],
}),
},
})
);
// Queue 2: analytics — all events, raw delivery
const analyticsQueue = new sqs.Queue(this, "AnalyticsQueue", {
visibilityTimeout: Duration.minutes(5), // analytics may be slower to process
retentionPeriod: Duration.days(4),
});
probeResultsTopic.addSubscription(
new subscriptions.SqsSubscription(analyticsQueue, {
rawMessageDelivery: true,
// no filter policy — receives all events
})
);
// Queue 3: s3-archiver — all events, with envelope (consumer uses MessageId for dedup)
const archiverQueue = new sqs.Queue(this, "ArchiverQueue", {
visibilityTimeout: Duration.seconds(60),
});
probeResultsTopic.addSubscription(
new subscriptions.SqsSubscription(archiverQueue, {
rawMessageDelivery: false, // keep envelope — archiver uses MessageId for idempotency
})
);
Cross-account fan-out
SNS can deliver to SQS queues in a different AWS account. The queue account must grant sqs:SendMessage in its queue resource policy, and the SNS topic must be configured with a policy that allows cross-account publish if the topic is in a different account than the publisher.
// Cross-account SQS queue resource policy (in the target account)
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "sns.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:999999999999:cross-account-analytics-queue",
"Condition": {
"ArnEquals": {
"aws:SourceArn": "arn:aws:sns:us-east-1:123456789012:mcp-probe-results"
}
}
}]
}
// Subscribe the cross-account queue via CLI (CDK does not support cross-account topic subscriptions natively)
aws sns subscribe \
--topic-arn arn:aws:sns:us-east-1:123456789012:mcp-probe-results \
--protocol sqs \
--notification-endpoint arn:aws:sqs:us-east-1:999999999999:cross-account-analytics-queue \
--profile source-account-profile
Encryption and cross-account: If the SQS queue uses an AWS-managed key (aws/sqs), SNS cannot deliver cross-account because SNS does not have permission to use the managed key from another account. Use a customer-managed KMS key (CMK) with an explicit key policy granting kms:GenerateDataKey and kms:Decrypt to the sns.amazonaws.com service principal for cross-account encrypted fan-out.
Cross-region fan-out constraints
| Scenario | Supported | Notes |
|---|---|---|
| Standard topic → SQS queue in same region, same account | Yes | Most common setup |
| Standard topic → SQS queue in different region, same account | Yes (limited) | SNS delivers cross-region; higher latency; not all regions support cross-region delivery |
| Standard topic → SQS queue in different account, same region | Yes | Queue resource policy must allow SNS |
| FIFO topic → SQS queue in different region | No | FIFO topics only deliver to same-region subscribers |
| Multi-region fan-out (one publish → EU and US queues) | Indirectly | Use EventBridge cross-region event replication as the router; subscribe regional SNS topics to EventBridge buses per region |
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Publish succeeds but queue receives no messages | Missing or incorrect SQS resource policy | Add sqs:SendMessage allow for sns.amazonaws.com with correct aws:SourceArn condition |
Consumer sees double-encoded JSON (JSON.parse(msg.Body).Message is a string) | Raw message delivery is off; consumer assumes raw delivery | Enable RawMessageDelivery: true on the subscription, or update consumer to unwrap the SNS envelope |
Cross-account delivery fails with KMSAccessDeniedException | Queue uses aws/sqs managed key; SNS cannot use managed keys cross-account | Create a CMK, add key policy granting kms:GenerateDataKey and kms:Decrypt to sns.amazonaws.com |
| Only one queue receives messages despite multiple subscriptions | Other queues' subscriptions have filter policies that exclude all published messages | Test each subscription in isolation; check attribute DataType matches filter operator type |
| Messages appear in DLQ instead of main queue | SNS delivery retries exhausted; SNS subscription DLQ received the message instead of the main queue | Check SNS delivery status logs in CloudWatch; inspect subscription DLQ ARN in subscription attributes |