Guide · AWS SNS
SNS Subscription Filter Policies for MCP Servers — Attribute Routing, String Match, Numeric Operators
SNS subscription filter policies let each subscriber declare which messages it wants to receive — SNS evaluates the policy at delivery time and drops non-matching messages before they reach the subscriber. Without filtering, every subscriber receives every message published to the topic, which means a queue or Lambda wired up to receive only critical alerts still processes thousands of healthy-probe events per day. Filter policies eliminate that unnecessary processing: publish once to a central status topic and route degraded/unhealthy events to an alerting queue, healthy events to an archiving queue, and latency_ms > 5000 events to a SLO dashboard — all from one topic, with zero application code in the router.
TL;DR
Attach a FilterPolicy JSON object to a subscription. Keys are message attribute names (or JSON body field paths with FilterPolicyScope: MessageBody). Values are arrays of match conditions. Multiple keys = AND. Multiple array values per key = OR. The 5 KB limit on filter policy JSON is per subscription, not per topic. Messages that match no subscription's filter policy are still counted as delivered (and billed) at the topic level but produce no downstream delivery.
Filter policy scope: message attributes vs message body
SNS supports two filter policy scopes, set per subscription via the FilterPolicyScope attribute:
| Scope | FilterPolicyScope value | What is matched |
|---|---|---|
| Message attributes (default) | MessageAttributes | The MessageAttributes map passed in the Publish call. Faster evaluation; attributes are not part of the body, so no JSON parse needed. |
| Message body | MessageBody | The JSON body of the published message. Keys in the filter policy must match JSON field paths. Top-level fields only — nested objects need to be expressed as dot-path strings (e.g., "endpoint.region"). |
For MCP monitoring, prefer message attributes: they are set explicitly at publish time, avoid coupling the filter policy to your JSON schema, and do not require SNS to parse the message body on every delivery attempt. Reserve MessageBody scope for legacy publishers you cannot modify to add attributes.
String operators
String operators apply to String-typed message attributes or string-valued JSON body fields.
| Operator | Filter policy JSON | Matches |
|---|---|---|
| Exact match | ["unhealthy"] | Attribute value is exactly "unhealthy" |
| OR (multiple values) | ["degraded", "unhealthy"] | Attribute value is "degraded" or "unhealthy" |
| Prefix match | [{"prefix": "us-east"}] | Attribute value starts with us-east |
| Suffix match | [{"suffix": "-prod"}] | Attribute value ends with -prod |
| Anything-but (exclusion) | [{"anything-but": ["healthy"]}] | Attribute value is anything except "healthy" |
| Exists (attribute present) | [{"exists": true}] | The named attribute exists in the message, regardless of value |
| Not exists | [{"exists": false}] | The named attribute is absent from the message |
| Equals-ignore-case | [{"equals-ignore-case": "UNHEALTHY"}] | Case-insensitive exact match |
// Publish with message attributes for filter policy evaluation
await snsClient.send(new PublishCommand({
TopicArn: process.env.STATUS_TOPIC_ARN,
Message: JSON.stringify({ endpointId, status, latencyMs, region }),
MessageAttributes: {
status: { DataType: "String", StringValue: status }, // "healthy"|"degraded"|"unhealthy"
region: { DataType: "String", StringValue: region }, // "us-east-1", "eu-west-1", ...
severity: { DataType: "String", StringValue: getSeverity(status, latencyMs) }, // "low"|"medium"|"high"|"critical"
latencyMs: { DataType: "Number", StringValue: String(latencyMs) },
},
}));
Numeric operators
Numeric operators apply to Number-typed message attributes. The attribute DataType must be "Number"; string attributes with numeric content are not evaluated as numbers.
| Operator | Filter policy JSON | Matches |
|---|---|---|
| Equal | [{"numeric": ["=", 200]}] | Value equals 200 |
| Not equal | [{"numeric": ["!=", 200]}] | Value is not 200 |
| Greater than | [{"numeric": [">", 5000]}] | Value > 5000 (latency alert) |
| Greater than or equal | [{"numeric": [">=", 5000]}] | Value >= 5000 |
| Less than | [{"numeric": ["<", 100]}] | Value < 100 |
| Range (between) | [{"numeric": [">", 1000, "<", 5000]}] | 1000 < value < 5000 |
Numeric operators are useful for latency-tier routing: send latencyMs > 5000 events to a SLO-breach queue, latencyMs between 1000 and 5000 to a degraded-performance queue, and latencyMs < 1000 to a healthy archive queue — all from a single publish call.
AND and OR semantics
Understanding AND vs OR is critical to writing correct filter policies:
- OR: Multiple values within one key's array are evaluated as OR.
"status": ["degraded", "unhealthy"]matches messages where status is either degraded or unhealthy. - AND: Multiple top-level keys in the filter policy object are evaluated as AND. A message must satisfy all key conditions to match.
// Filter policy: (status is "degraded" OR "unhealthy") AND (region starts with "eu-")
// This subscription only receives alerts from European endpoints with non-healthy status
{
"status": ["degraded", "unhealthy"],
"region": [{ "prefix": "eu-" }]
}
// Filter policy: latencyMs > 3000 AND severity is "high" OR "critical"
// Routes high-latency critical alerts to an ops pager queue
{
"latencyMs": [{ "numeric": [">", 3000] }],
"severity": ["high", "critical"]
}
Combining numeric and string conditions: You can mix string and numeric operators within a single filter policy. The AND rule applies across all key types. A message with latencyMs = 4500 (matches > 3000) but severity = "low" (does not match ["high", "critical"]) will not be delivered to this subscription.
CDK: subscriptions with filter policies
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";
declare const statusTopic: sns.Topic;
// Queue 1: critical alerts only (status unhealthy OR degraded AND severity critical)
const criticalAlertQueue = new sqs.Queue(this, "CriticalAlertQueue");
statusTopic.addSubscription(
new subscriptions.SqsSubscription(criticalAlertQueue, {
rawMessageDelivery: true,
filterPolicy: {
status: sns.SubscriptionFilter.stringFilter({
allowlist: ["degraded", "unhealthy"],
}),
severity: sns.SubscriptionFilter.stringFilter({
allowlist: ["high", "critical"],
}),
},
})
);
// Queue 2: SLO latency breach (latencyMs > 5000, any status)
const sloBreachQueue = new sqs.Queue(this, "SloBreachQueue");
statusTopic.addSubscription(
new subscriptions.SqsSubscription(sloBreachQueue, {
rawMessageDelivery: true,
filterPolicy: {
latencyMs: sns.SubscriptionFilter.numericFilter({
greaterThan: 5000,
}),
},
})
);
// Queue 3: EU region all events (for regional dashboards)
const euRegionQueue = new sqs.Queue(this, "EuRegionQueue");
statusTopic.addSubscription(
new subscriptions.SqsSubscription(euRegionQueue, {
rawMessageDelivery: true,
filterPolicyWithMessageBody: {
region: sns.FilterOrPolicy.filter(
sns.SubscriptionFilter.stringFilter({ matchPrefixes: ["eu-"] })
),
},
})
);
Filter policy size limit and evaluation performance
| Constraint | Value | Notes |
|---|---|---|
| Max filter policy size per subscription | 5 KB (message attributes scope) | Complex policies with many attribute conditions can hit this limit |
| Max filter policy size per subscription (MessageBody scope) | 5 KB | Same limit applies |
| Max values in an array for one attribute | 150 | Limits OR fan-out per attribute |
| Max attributes in one filter policy | 5 | Max 5 AND conditions in a single policy |
| Filter evaluation cost | No additional charge | Messages that fail the filter are not delivered but ARE counted for topic message throughput pricing |
Billing note: SNS charges per message published to the topic, not per delivery. If you publish 10,000 healthy-probe events per day to a topic with a filter policy that excludes healthy events, those 10,000 messages are still billed as published. Only use SNS filtering to reduce downstream processing cost — not to reduce SNS publishing cost.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Subscriber receives no messages despite matching intent | Message attribute DataType mismatch — attribute published as String but filter uses numeric operator | Ensure numeric filter attributes use DataType: "Number" at publish time |
| All messages pass filter regardless of policy | FilterPolicyScope not set — policy uses body field paths but scope defaults to MessageAttributes | Set FilterPolicyScope: MessageBody on the subscription via SetSubscriptionAttributes |
| Policy update takes effect slowly | SNS propagates filter policy changes asynchronously; up to 15 minutes for all endpoints to pick up new policy | Wait 15 minutes after SetSubscriptionAttributes before testing; do not assume immediate effect |
| Policy exceeds 5 KB | Too many attribute conditions or large allowlists | Split into multiple subscriptions; or move high-cardinality routing to a Lambda subscriber that applies custom logic |
| Anything-but filter lets through unexpected values | Anything-but with a string value (not array) is treated as exact-match exclusion only | Use array syntax: {"anything-but": ["healthy", "recovering"]} |