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:

// 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:

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