Guide · AWS Core Services

MCP Server SNS — event fanout, topic subscriptions, message filtering

Amazon SNS lets MCP tools broadcast events to multiple consumers in a single Publish call — SQS queues, Lambda functions, HTTP/S endpoints, and email addresses all subscribe to the same topic and each receive a copy. The three things that trip developers up are: SNS is push-only (there is no poll loop — SNS delivers to subscribers; if a subscriber is down, retry behavior is limited), HTTP subscriptions require subscription confirmation (SNS posts a SubscriptionConfirmation request that the endpoint must accept before messages flow), and message filtering is per-subscription, not per-publish (filter policies reduce the delivery load for each subscriber without requiring a separate topic per event type).

TL;DR

Install @aws-sdk/client-sns. Create an SNSClient once at startup. Use PublishCommand to emit events from MCP tools. Subscribe SQS queues to the topic for durable delivery (SQS buffers the event even if your subscriber is down). Use MessageAttributes with filter policies on subscriptions to route events without multiplying topics.

Client setup and topic ARN

The SNS client is initialised once and shared. The topic ARN is the stable identifier — it encodes account, region, and topic name and is the only identifier SNS uses for publish operations.

import { SNSClient, PublishCommand } from "@aws-sdk/client-sns";

const sns = new SNSClient({ region: process.env.AWS_REGION ?? "us-east-1" });

// Topic ARN format: arn:aws:sns:{region}:{account-id}:{topic-name}
// FIFO topic ARN: arn:aws:sns:{region}:{account-id}:{topic-name}.fifo
const TOPIC_ARN = process.env.SNS_TOPIC_ARN!;

// Publish a simple event from an MCP tool
await sns.send(new PublishCommand({
  TopicArn: TOPIC_ARN,
  Subject: "JobCompleted",     // optional; shown in email subscriptions; ignored by SQS/Lambda
  Message: JSON.stringify({ jobId: "abc123", status: "success", at: new Date().toISOString() }),
  MessageAttributes: {
    eventType: { DataType: "String", StringValue: "job.completed" },
    priority:  { DataType: "Number", StringValue: "1" },
  },
}));

SNS-to-SQS fan-out: the reliable pattern

Publishing to an SNS topic that has SQS subscriptions is the standard fan-out pattern: SNS delivers to each subscribed SQS queue, which buffers the message durably. If a downstream consumer is down, it reads the message from the queue when it recovers. HTTP subscriptions do not have this buffer — if the HTTP endpoint is down during delivery, the message may be lost after retry exhaustion.

// AWS CDK: SNS topic with two SQS subscriptions
import * as sns from "aws-cdk-lib/aws-sns";
import * as sqs from "aws-cdk-lib/aws-sqs";
import * as subs from "aws-cdk-lib/aws-sns-subscriptions";

const topic = new sns.Topic(this, "EventTopic", {
  topicName: "mcp-events",
});

const analyticsQueue = new sqs.Queue(this, "AnalyticsQueue");
const auditQueue = new sqs.Queue(this, "AuditQueue");

// Both queues receive every event published to the topic
topic.addSubscription(new subs.SqsSubscription(analyticsQueue));
topic.addSubscription(new subs.SqsSubscription(auditQueue));

// The SNS message body is wrapped when delivered to SQS:
// { "Type": "Notification", "TopicArn": "...", "Message": "{...your JSON...}", ... }
// Parse: const snsEnvelope = JSON.parse(sqsMessage.Body); const payload = JSON.parse(snsEnvelope.Message);

When SNS delivers to SQS, the SQS message body is the SNS notification envelope — a JSON object with Type, TopicArn, Subject, Message, MessageAttributes, and timestamp fields. You must double-parse: first the SQS message body to get the SNS envelope, then envelope.Message to get your original payload.

Message filtering: one topic, many consumers

Filter policies on subscriptions let each subscriber receive only the events it cares about, without requiring a separate topic per event type. Filters operate on MessageAttributes set at publish time.

// AWS CDK: subscription with filter policy
import * as sns from "aws-cdk-lib/aws-sns";
import * as subs from "aws-cdk-lib/aws-sns-subscriptions";

// Only this subscription receives events where eventType = "job.completed"
analyticsQueue.addSubscription(new subs.SqsSubscription(analyticsQueue, {
  filterPolicy: {
    eventType: sns.SubscriptionFilter.stringFilter({
      allowlist: ["job.completed", "job.failed"],
    }),
  },
}));

// Numeric filter: only receive high-priority events
auditQueue.addSubscription(new subs.SqsSubscription(auditQueue, {
  filterPolicy: {
    priority: sns.SubscriptionFilter.numericFilter({
      greaterThanOrEqualTo: 2,
    }),
  },
}));

// Publish with the matching attribute — both filters evaluated at delivery
await sns.send(new PublishCommand({
  TopicArn: TOPIC_ARN,
  Message: JSON.stringify({ jobId: "abc123" }),
  MessageAttributes: {
    eventType: { DataType: "String", StringValue: "job.completed" },
    priority:  { DataType: "Number", StringValue: "2" },
  },
}));
// This message reaches both subscriptions: eventType matches analytics filter, priority ≥2 matches audit filter

Filter policies are evaluated by SNS before delivery — subscribers that do not match are not charged for the receive. This is the standard way to split a high-volume event stream without creating dozens of topics.

HTTP subscriptions: confirmation flow

When you subscribe an HTTP/S endpoint to an SNS topic, SNS immediately sends a SubscriptionConfirmation POST to that endpoint. The endpoint must respond with HTTP 200 and the body is ignored — but SNS also includes a SubscribeURL in the payload that the endpoint must fetch (HTTP GET) to confirm. Until confirmed, the subscription is pending and no messages are delivered.

// Express.js handler for SNS HTTP subscription confirmation and message delivery
import express from "express";
import fetch from "node-fetch";

const app = express();
app.use(express.text({ type: "application/json" }));

app.post("/webhooks/sns", async (req, res) => {
  const body = JSON.parse(req.body);

  // Step 1: confirm the subscription (first POST from SNS)
  if (body.Type === "SubscriptionConfirmation") {
    await fetch(body.SubscribeURL);  // GET request to the URL in the payload
    res.status(200).end();
    return;
  }

  // Step 2: handle actual messages
  if (body.Type === "Notification") {
    const payload = JSON.parse(body.Message);
    // process payload...
    res.status(200).end();  // MUST respond 200 before processing to avoid SNS retry
    return;
  }

  res.status(200).end();  // UnsubscribeConfirmation or unknown — acknowledge and ignore
});
// CRITICAL: SNS verifies the endpoint responds 200 within 15 seconds.
// For slow processing, respond 200 first, then process asynchronously.

Publishing from MCP tools: patterns

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({ name: "event-emitter", version: "1.0.0" });

server.tool(
  "emit_event",
  "Publish a named event to the SNS topic for downstream consumers",
  {
    event_type: z.string().describe("Dot-separated event name, e.g. user.created or order.shipped"),
    payload: z.record(z.unknown()).describe("Event payload as a JSON object"),
    priority: z.number().int().min(1).max(3).default(1).describe("1=low, 2=medium, 3=high"),
  },
  async ({ event_type, payload, priority }) => {
    const result = await sns.send(new PublishCommand({
      TopicArn: TOPIC_ARN,
      Message: JSON.stringify({ ...payload, _emitted_at: new Date().toISOString() }),
      MessageAttributes: {
        eventType: { DataType: "String", StringValue: event_type },
        priority:  { DataType: "Number", StringValue: String(priority) },
      },
    }));

    return {
      content: [{ type: "text", text: JSON.stringify({ messageId: result.MessageId, event_type }) }],
    };
  }
);

SNS FIFO topics

SNS FIFO topics preserve message order and deduplicate within a 5-minute window, mirroring SQS FIFO semantics. They can only deliver to SQS FIFO queues — not to Lambda, HTTP, or email subscriptions. Use FIFO topics when downstream consumers must process events in the exact order they were published, such as ledger entry sequences or ordered state transitions.

import { PublishCommand } from "@aws-sdk/client-sns";

// FIFO topic publish requires MessageGroupId; MessageDeduplicationId is optional
// if ContentBasedDeduplication is enabled on the topic
await sns.send(new PublishCommand({
  TopicArn: process.env.SNS_FIFO_TOPIC_ARN!,  // ARN must end in .fifo
  Message: JSON.stringify({ orderId: "ORD-999", event: "payment.captured" }),
  MessageGroupId: "order-ORD-999",         // all events for this order stay in sequence
  MessageDeduplicationId: "payment-captured-ORD-999-" + Date.now(),
  MessageAttributes: {
    eventType: { DataType: "String", StringValue: "payment.captured" },
  },
}));

IAM policy for SNS publish

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["sns:Publish"],
      "Resource": "arn:aws:sns:us-east-1:123456789012:mcp-events"
    }
  ]
}
// For an MCP server that only emits events, sns:Publish on the specific topic ARN is sufficient.
// Never grant sns:* — it allows topic deletion and subscription manipulation.

Common failure modes

SymptomCauseFix
HTTP subscription stuck in "Pending confirmation"Endpoint never fetched SubscribeURLImplement SubscriptionConfirmation handler that fetches SubscribeURL immediately
Subscriber receives SNS envelope, not original payloadDouble-serialisation not handledParse sqsMsg.Body → SNS envelope, then parse envelope.Message → your payload
Filter policy not appliedMessageAttributes not set at publish timeSet MessageAttributes in PublishCommand matching the filter policy keys
FIFO topic delivery failsSubscriber SQS queue is Standard (not FIFO)FIFO SNS topics can only deliver to FIFO SQS queues; create queue with .fifo suffix
HTTP endpoint receives duplicate messagesSNS retrying on non-200 responseAlways respond 200 before processing; handle idempotency on the consumer side
403 AuthorizationError on PublishTask role missing sns:Publish on topic ARNAdd sns:Publish permission for the specific topic ARN to the IAM role