Guide · AWS EventBridge Pipes
EventBridge Pipes Filter Expressions
Filter expressions in EventBridge Pipes evaluate source records before they reach enrichment or the target, dropping records that don't match. For MCP servers that funnel all tool call events through a single SQS queue, this means you can route only tool_call events to a DynamoDB pipeline while silently discarding heartbeat and metrics events — without writing any Lambda code. Three things teams get wrong with Pipes filters: multiple conditions within a single filter pattern are AND-joined (all conditions must match), while multiple filter patterns in the FilterCriteria list are OR-joined (a record matches if any pattern matches). This is the opposite of what many people expect. Second, the filter pattern is a JSON-encoded string nested inside the pipe definition — in CDK/CloudFormation you must call JSON.stringify() on the pattern object; a plain object reference silently becomes an empty pattern and passes all records. Third, SQS message attributes are accessed as $.messageAttributes.KEY.stringValue, not in $.body — failing to use the right path means your tenant-based filter silently passes all records.
TL;DR
Write filter patterns as JSON objects and pass them as JSON.stringify(pattern) in CDK. Multiple conditions in one pattern are AND logic; multiple patterns in filters are OR logic. Access the SQS message body via $.body.field after parsing, or access message attributes via $.messageAttributes.KEY.stringValue. Available operators: exact match, prefix, suffix, anything-but, exists, is-blank, numeric comparisons, and CIDR range.
Filter pattern structure
A Pipes filter pattern is structurally identical to an EventBridge event pattern on a bus rule. The pattern is a JSON object where each key corresponds to a path in the source record, and the value is an array of match conditions (array = OR for that field's conditions).
For SQS sources, the record structure that the filter sees is:
{
"messageId": "abc-123",
"receiptHandle": "...",
"body": "{\"eventType\":\"tool_call\",\"tenantId\":\"t-99\",\"toolName\":\"search\"}",
"attributes": {
"ApproximateReceiveCount": "1",
"SentTimestamp": "1727126400000"
},
"messageAttributes": {
"tenantId": { "stringValue": "t-99", "dataType": "String" },
"priority": { "stringValue": "high", "dataType": "String" }
},
"eventSource": "aws:sqs",
"eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:mcp-tool-calls"
}
Note that body is a string — the raw SQS message body. EventBridge Pipes automatically parses it as JSON when evaluating filter patterns against $.body.* fields. You do not need to pre-parse it; the filter engine treats it as a nested object for pattern matching purposes.
AND vs OR logic
This is the most common source of filtering bugs:
- Multiple conditions on different fields within a single filter pattern → AND. All conditions must match for the record to be passed. Example:
{"body": {"eventType": ["tool_call"], "priority": ["high"]}}passes only records where both eventType is tool_call AND priority is high. - Multiple conditions on the same field in the array → OR for that field. Example:
{"body": {"eventType": ["tool_call", "tool_result"]}}passes records where eventType is tool_call OR tool_result. - Multiple patterns in the
filterslist → OR across patterns. A record is passed if it matches any pattern in the list.
// CDK — filter patterns demonstrating AND vs OR logic
const pipe = new pipes.CfnPipe(this, "RoutingPipe", {
// ...
sourceParameters: {
sqsQueueParameters: { batchSize: 10 },
filterCriteria: {
filters: [
// Pattern 1: eventType = tool_call AND priority = high (AND logic)
{
pattern: JSON.stringify({
body: {
eventType: ["tool_call"],
priority: ["high"],
},
}),
},
// Pattern 2: eventType = tool_result (separate pattern = OR with Pattern 1)
{
pattern: JSON.stringify({
body: {
eventType: ["tool_result"],
},
}),
},
],
// Combined effect: pass if (eventType=tool_call AND priority=high) OR (eventType=tool_result)
},
},
// ...
});
Full operator reference
| Operator | Syntax | Example | Notes |
|---|---|---|---|
| Exact match | "value" (string literal in array) |
{"body": {"status": ["error"]}} |
Case-sensitive; works for strings, numbers, booleans, null |
| Prefix | {"prefix": "str"} |
{"body": {"toolName": [{"prefix": "search_"}]}} |
Matches if string starts with prefix; useful for namespaced tool names |
| Suffix | {"suffix": "str"} |
{"body": {"fileName": [{"suffix": ".pdf"}]}} |
Matches if string ends with suffix |
| Anything-but (string) | {"anything-but": ["v1","v2"]} |
{"body": {"eventType": [{"anything-but": ["heartbeat"]}]}} |
Passes records where field is NOT one of the listed values; great for excluding noise events |
| Anything-but (prefix) | {"anything-but": {"prefix": "str"}} |
{"body": {"toolName": [{"anything-but": {"prefix": "internal_"}}]}} |
Passes records where field does NOT start with prefix |
| Exists | {"exists": true} or {"exists": false} |
{"body": {"errorCode": [{"exists": true}]}} |
exists:true passes only records where field is present (not null); exists:false passes records where field is absent |
| Is-blank | {"is-blank": true} |
{"body": {"sessionId": [{"is-blank": true}]}} |
Passes records where field is an empty string |
| Numeric (range) | {"numeric": [">=", 0, "<", 1000]} |
{"body": {"durationMs": [{"numeric": [">", 5000]}]}} |
Only works on numeric fields; operators: =, !=, >, >=, <, <=; range uses pairs |
| CIDR | {"cidr": "10.0.0.0/8"} |
{"body": {"sourceIp": [{"cidr": "10.0.0.0/8"}]}} |
Matches if field value is an IP address within the CIDR block; supports IPv4 and IPv6 |
Nested JSON with dot-notation
For deeply nested fields in the SQS body, use the natural JSON nesting structure in the filter pattern — not dot-notation string keys. EventBridge Pipes (like EventBridge Rules) uses nested object matching, not JSONPath dot-notation in the key name.
// Tool call event body structure (nested)
// {
// "event": {
// "type": "tool_call",
// "context": {
// "tenant": { "id": "t-99", "plan": "team" }
// }
// }
// }
// CORRECT — nested object matching
const correctFilter = {
body: {
event: {
type: ["tool_call"],
context: {
tenant: {
plan: ["team", "enterprise"], // OR: team or enterprise plan
},
},
},
},
};
// WRONG — dot-notation string keys don't work in filter patterns
const wrongFilter = {
"body.event.type": ["tool_call"], // This never matches anything
};
// CDK usage
sourceParameters: {
filterCriteria: {
filters: [{ pattern: JSON.stringify(correctFilter) }],
},
}
Filtering on SQS message attributes
SQS message attributes are separate from the message body and are accessed via a different path in the filter pattern. This is commonly used for multi-tenant MCP server architectures where the tenant ID is set as a message attribute by the producer, allowing routing without parsing the body.
// SQS producer: set tenantId as a message attribute
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
const sqs = new SQSClient({ region: "us-east-1" });
await sqs.send(new SendMessageCommand({
QueueUrl: process.env.QUEUE_URL,
MessageBody: JSON.stringify({ toolName: "search", query: "..." }),
MessageAttributes: {
tenantId: {
DataType: "String",
StringValue: "t-99",
},
priority: {
DataType: "String",
StringValue: "high",
},
},
}));
// Pipes filter: match on message attribute
const attrFilter = {
messageAttributes: {
// Filter on tenantId attribute
tenantId: {
stringValue: ["t-99", "t-100"], // OR: either tenant
},
// AND filter on priority
priority: {
stringValue: ["high"],
},
},
};
// Combined body + attribute filter
const combinedFilter = {
body: {
eventType: ["tool_call"],
},
messageAttributes: {
priority: { stringValue: ["high"] },
},
};
Important: the message attribute filter syntax uses the attribute name as a nested key with stringValue, numberValue, or binaryValue properties matching the DataType set by the producer. Binary attributes (binaryValue) are base64-encoded strings in filter patterns.
Filter vs enrichment Lambda — when filtering isn't enough
Filter expressions evaluate static pattern matching. They cannot:
- Call external services (DynamoDB, SSM Parameter Store) to look up whether a tenant is allowed
- Apply complex business logic (e.g., rate-limit checks based on event history)
- Parse non-JSON body formats (XML, CSV, custom binary)
- Access or compare fields from different events in the batch
For these cases, use the enrichment Lambda as a filter: return an empty array [] from the enrichment function for records that should be dropped. EventBridge Pipes treats an empty enrichment response as "no records to pass to target" for that batch — effectively filtering them out.
// Enrichment Lambda as dynamic filter + enricher
exports.handler = async (events) => {
const tenantCache = new Map();
const enriched = await Promise.all(events.map(async (evt) => {
const body = JSON.parse(evt.body);
// Dynamic check: look up tenant plan in DynamoDB
const tenantId = body.tenantId;
if (!tenantCache.has(tenantId)) {
const result = await dynamodb.getItem({
TableName: "tenants",
Key: { tenantId: { S: tenantId } },
}).promise();
tenantCache.set(tenantId, result.Item?.plan?.S ?? "free");
}
const plan = tenantCache.get(tenantId);
// Drop free-tier events from this high-priority pipeline
if (plan === "free") return null;
return { ...body, tenantPlan: plan, enrichedAt: new Date().toISOString() };
}));
// Filter out dropped records (null values)
// Pipes treats empty array response as "pass 0 records to target"
return enriched.filter(Boolean);
};
Common filter pattern mistakes
| Symptom | Root cause | Fix |
|---|---|---|
| All records pass the filter despite pattern being set | Pattern passed as object, not JSON string (CDK: missing JSON.stringify()) |
Wrap pattern in JSON.stringify() in CDK/CloudFormation |
| Filter works in isolation but not on nested field | Used dot-notation key ("body.event.type") instead of nested object structure |
Use nested object: {"body": {"event": {"type": ["v"]}}} |
| Message attribute filter matches nothing | Accessing attribute via $.body.tenantId instead of messageAttributes.tenantId.stringValue |
Use {"messageAttributes": {"tenantId": {"stringValue": ["t-99"]}}} |
| AND filter behaves like OR | Conditions split across multiple patterns in filters array instead of combined in one pattern |
Put all AND conditions in one pattern object; use multiple patterns only for OR |
| Numeric filter on duration field passes all records | Field is a string ("durationMs": "450") not a number ("durationMs": 450) |
Fix producer to emit numeric JSON value; numeric operator only works on JSON number type |