Guide · AWS EventBridge Pipes

EventBridge Pipes for MCP Servers

EventBridge Pipes is a point-to-point integration service that connects a source (SQS, DynamoDB Streams, Kinesis, MSK) directly to a target without routing through an event bus. For MCP servers this matters because a common pattern is: tool call events flow into an SQS queue → you need to enrich them with tenant metadata → then write them to DynamoDB or invoke a downstream Lambda. With Rules you need a bus hop and a custom event format; with Pipes you express the entire pipeline in a single resource with built-in filtering, optional enrichment, and retry semantics. Three things teams consistently get wrong: Pipes run serially by default — a pipe processes one batch at a time from each source shard/queue; scaling comes from the source's natural parallelism (Kinesis shards, SQS VisibilityTimeout), not from spawning concurrent Pipe instances per event. The IAM execution role requires permissions at every stage — source read, enrichment invoke, and target write must all be on the same role; forgetting enrichment invoke permissions leaves the pipe in CREATE_FAILED state silently. Payload size is capped at 6 MB between source and enrichment and between enrichment and target — if your MCP tool call events carry large binary payloads (base64-encoded screenshots, file contents) you need to store the payload in S3 and pass a reference object through the pipe.

TL;DR

EventBridge Pipes is the right tool when your source is a stream/queue (not an EventBridge bus) and you want filtering + optional enrichment + a single target in one managed resource. Grant the execution role permissions for source, enrichment, and target in one IAM policy. Use CfnPipe in CDK (L1 construct — no L2 yet as of 2026). Expect serial processing within each pipe; horizontal scale comes from source-level parallelism, not multiple pipe instances.

Pipes vs EventBridge Rules — when to use each

EventBridge Rules and EventBridge Pipes both move events between AWS services but at different layers of the architecture.

EventBridge Rules sit on an event bus and match events that were published to that bus via PutEvents. They're the right choice when: multiple independent consumers subscribe to the same event type (fan-out), the event source already puts events on a bus (AWS service integrations like S3/EC2/ECS all emit to the default bus), or you need content-based routing to different targets based on event fields.

EventBridge Pipes poll a source directly and don't need an event bus at all. They're the right choice when: your source is already a stream (Kinesis) or queue (SQS) and putting events on a bus first is an extra hop; you want built-in batching from the source; you need one enrichment Lambda called for every batch before the target receives events; or you want to avoid the double-serialization of wrapping source payloads in an EventBridge envelope.

Dimension EventBridge Rules EventBridge Pipes
Source type Events on an event bus (PutEvents or AWS service events) Streaming/queue source: SQS, DynamoDB Streams, Kinesis, MSK
Fan-out Up to 5 targets per rule One target per pipe
Enrichment Not built-in (use Lambda target + Lambda invokes downstream) Built-in: Lambda, API Gateway, Step Functions
Batching One event per target invocation Configurable batch size from source
Filter expression Event patterns on bus message Filter on source record body/attributes (applied before enrichment)
Retry / DLQ Per-target DLQ (only on SQS/Kinesis targets) Pipe-level DLQ; retry on enrichment/target failures

Pipe lifecycle states

A pipe transitions through states that you must understand to debug creation failures and operational issues. Unlike Lambda, a pipe doesn't "fail silently" on a bad invocation — it stops the source poller entirely when errors accumulate.

// CDK — check pipe state and state reason after creation
import { aws_pipes as pipes } from "aws-cdk-lib";

// CfnPipe is the L1 construct — no L2 as of 2026
const pipe = new pipes.CfnPipe(this, "McpEventPipe", {
  name: "mcp-tool-call-router",
  roleArn: pipeRole.roleArn,
  source: sqsQueue.queueArn,
  sourceParameters: {
    sqsQueueParameters: {
      batchSize: 10,
      maximumBatchingWindowInSeconds: 5,
    },
  },
  // Optional filter (see filtering guide)
  // Optional enrichment (see enrichment guide)
  target: targetLambda.functionArn,
  targetParameters: {
    lambdaFunctionParameters: {
      invocationType: "FIRE_AND_FORGET",  // or "REQUEST_RESPONSE"
    },
  },
});

// Post-deploy: check state via CLI
// aws pipes describe-pipe --name mcp-tool-call-router \
//   --query '{State: State, StateReason: StateReason}'

IAM execution role — grant permissions at every stage

A single IAM role must cover all three stages of the pipe: source read, enrichment invoke (if configured), and target write. Missing any one permission causes CREATE_FAILED and the StateReason message names the specific denied API.

import {
  Role, ServicePrincipal, PolicyStatement, Effect
} from "aws-cdk-lib/aws-iam";
import { Queue } from "aws-cdk-lib/aws-sqs";
import { Function } from "aws-cdk-lib/aws-lambda";
import { Table } from "aws-cdk-lib/aws-dynamodb";

// Pipe execution role — one role for all three stages
const pipeRole = new Role(this, "McpPipeRole", {
  assumedBy: new ServicePrincipal("pipes.amazonaws.com"),
  // Scope trust to this account/region to prevent confused deputy
  conditions: {
    StringEquals: {
      "aws:SourceAccount": this.account,
      "aws:SourceArn": `arn:aws:pipes:${this.region}:${this.account}:pipe/mcp-tool-call-router`,
    },
  },
});

// Stage 1 — source read (SQS)
pipeRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: [
    "sqs:ReceiveMessage",
    "sqs:DeleteMessage",
    "sqs:GetQueueAttributes",
  ],
  resources: [sqsQueue.queueArn],
}));

// Stage 2 — enrichment invoke (Lambda)
pipeRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: ["lambda:InvokeFunction"],
  resources: [enrichmentLambda.functionArn],
}));

// Stage 3 — target write (DynamoDB)
pipeRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: [
    "dynamodb:PutItem",
    "dynamodb:UpdateItem",
  ],
  resources: [targetTable.tableArn],
}));

// If target is Lambda (invocationType: REQUEST_RESPONSE), use:
// pipeRole.addToPolicy(new PolicyStatement({
//   actions: ["lambda:InvokeFunction"],
//   resources: [targetLambda.functionArn],
// }));

Note the aws:SourceArn condition key in the trust policy. EventBridge Pipes uses a service role, which means the service assumes the role on your behalf. Without the source ARN condition, any pipe in your account — including one created by a compromised actor — could use this role. Scoping to a specific pipe ARN prevents that confused deputy attack.

Concurrent executions and throughput model

EventBridge Pipes does not spawn multiple concurrent invocations of the same pipe for the same source partition. The processing model is:

For SQS-sourced pipes where you need higher throughput, consider using Lambda ESM directly (which does scale horizontally across queue partitions) and reserving Pipes for patterns where the enrichment + target abstraction outweighs the throughput limitation.

Payload size limits

EventBridge Pipes enforces a 6 MB payload limit at each stage boundary:

For MCP servers that log full tool call request/response bodies (which can include large base64-encoded images or file contents), this limit matters. The standard pattern is to store the raw payload in S3 at ingestion time and put only a reference object (S3 bucket + key + metadata) into SQS/Kinesis. The enrichment Lambda then fetches from S3 if it needs the full body, adds tenant metadata to the reference object, and passes the enriched reference to the target. The target writes the reference to DynamoDB; downstream consumers pull the full payload from S3 only when needed.

Full CDK pipe with SQS source, Lambda enrichment, and DynamoDB target

import * as cdk from "aws-cdk-lib";
import { aws_pipes as pipes } from "aws-cdk-lib";
import { Queue } from "aws-cdk-lib/aws-sqs";
import { Function, Runtime, Code } from "aws-cdk-lib/aws-lambda";
import { Table, BillingMode, AttributeType } from "aws-cdk-lib/aws-dynamodb";
import { Role, ServicePrincipal, PolicyStatement } from "aws-cdk-lib/aws-iam";

export class McpPipelineStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // Source queue
    const toolCallQueue = new Queue(this, "ToolCallQueue", {
      queueName: "mcp-tool-calls",
      visibilityTimeout: cdk.Duration.seconds(120),  // >= enrichment + target time
      deadLetterQueue: {
        queue: new Queue(this, "ToolCallDlq", { queueName: "mcp-tool-calls-dlq" }),
        maxReceiveCount: 3,
      },
    });

    // Enrichment Lambda
    const enrichFn = new Function(this, "EnrichFn", {
      runtime: Runtime.NODEJS_22_X,
      handler: "index.handler",
      code: Code.fromInline(`
        exports.handler = async (events) => {
          // events is an array of SQS messages (batch)
          // Must return array of same length — each element replaces the source record
          return events.map(evt => ({
            ...JSON.parse(evt.body),
            tenant: evt.messageAttributes?.tenantId?.stringValue ?? "default",
            enrichedAt: new Date().toISOString(),
          }));
        };
      `),
      timeout: cdk.Duration.seconds(25),  // Pipes hard cap is 29s
    });

    // Target table
    const eventTable = new Table(this, "EventTable", {
      tableName: "mcp-tool-events",
      partitionKey: { name: "pk", type: AttributeType.STRING },
      sortKey: { name: "sk", type: AttributeType.STRING },
      billingMode: BillingMode.PAY_PER_REQUEST,
    });

    // Pipe IAM role
    const pipeRole = new Role(this, "PipeRole", {
      assumedBy: new ServicePrincipal("pipes.amazonaws.com"),
    });
    toolCallQueue.grantConsumeMessages(pipeRole);
    enrichFn.grantInvoke(pipeRole);
    eventTable.grantWriteData(pipeRole);

    // The pipe
    new pipes.CfnPipe(this, "McpEventPipe", {
      name: "mcp-tool-call-router",
      roleArn: pipeRole.roleArn,
      source: toolCallQueue.queueArn,
      sourceParameters: {
        sqsQueueParameters: {
          batchSize: 10,
          maximumBatchingWindowInSeconds: 5,
        },
        filterCriteria: {
          filters: [
            {
              // Only route events from MCP servers with eventType = tool_call
              pattern: JSON.stringify({
                body: { eventType: ["tool_call"] }
              }),
            },
          ],
        },
      },
      enrichment: enrichFn.functionArn,
      enrichmentParameters: {
        inputTemplate: "$.body",  // Pass only the SQS message body to enrichment
      },
      target: eventTable.tableArn,
      targetParameters: {
        dynamoDbParameters: {
          operation: "PUT_ITEM",
          // Input transformer maps enrichment output fields to DynamoDB attribute map
        },
      },
    });
  }
}

Common creation failures

State StateReason (excerpt) Fix
CREATE_FAILED lambda:InvokeFunction denied on enrichment ARN Add lambda:InvokeFunction to pipe role for enrichment function ARN
CREATE_FAILED sqs:ReceiveMessage denied Grant pipe role sqs:ReceiveMessage, sqs:DeleteMessage, sqs:GetQueueAttributes
CREATE_FAILED KMS decrypt permission denied SSE-KMS queue — add kms:Decrypt and kms:GenerateDataKey to pipe role
RUNNING_FAILED Enrichment returned non-2xx response Enrichment Lambda threw exception or returned error response; fix enrichment or increase maximumRetryAttempts
RUNNING_FAILED Target invocation failed after N retries Target Lambda/DynamoDB is failing; configure pipe DLQ and call StartPipe after fix