Guide · AWS EventBridge
MCP Server EventBridge Pub/Sub — event rules, custom bus, schema registry, target DLQs
EventBridge decouples MCP server tool calls from their side effects — a tool that writes to a database can emit a ToolCallCompleted event to a custom bus and let downstream subscribers (audit logger, webhook dispatcher, session summarizer) react without the MCP server knowing they exist. This pub/sub topology means adding a new subscriber requires no changes to the tool handler; removing a broken subscriber does not affect tool execution; and subscribers that fail are automatically retried by EventBridge's built-in retry mechanism rather than requiring the tool handler to implement retry logic. The four operational details that separate a well-configured EventBridge topology from one that silently drops events are custom event bus (never use the default bus for application events), event pattern design (match on detail-type and source not on detail fields when possible), target dead-letter queues (EventBridge retries failed targets independently of SQS DLQ configuration), and PutEvents payload limits (256 KB per event; batch of 10 events per call; large tool outputs must be stored externally with a reference pointer in the event).
TL;DR
Always create a custom event bus — never publish application events to the default bus. Set source to a domain-scoped string like com.yourapp.mcp and detail-type to a semantic name like ToolCallCompleted. Attach a dead-letter SQS queue to every EventBridge target — this is separate from any SQS queue DLQ. EventBridge events are 256 KB max — store large payloads in S3 and put only the S3 URI in the event detail. Enable the schema registry and validate event schemas at development time. EventBridge retries failed Lambda targets up to 24 hours with exponential backoff — set maximumRetryAttempts and maximumEventAge on each target to limit the retry window.
Custom event bus vs default bus
Every AWS account has a default event bus that receives events from AWS services (EC2 state changes, S3 bucket notifications, CloudTrail API calls). Publishing application events to the default bus mixes your events with AWS service events, makes event pattern matching noisy, and exposes your tool call payloads to any rule that happens to be running on the default bus (including rules created by CloudFormation, AWS Config, Security Hub, etc.).
Always create a custom event bus for application events. Custom buses are free (you pay per event, not per bus). Give the bus a domain-scoped name like mcp-tool-events or com.yourapp.mcp. The custom bus receives only events you explicitly publish to it — zero noise from AWS service events.
import {
EventBridgeClient,
PutEventsCommand,
type PutEventsRequestEntry,
} from '@aws-sdk/client-eventbridge';
const eb = new EventBridgeClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
// Minimum IAM permission for producers:
// events:PutEvents on the custom bus ARN
// events:PutEvents on arn:aws:events:REGION:ACCOUNT:event-bus/mcp-tool-events
const CUSTOM_BUS_NAME = process.env.EVENT_BUS_NAME ?? 'mcp-tool-events';
interface ToolCallCompletedEvent {
toolCallId: string;
toolName: string;
sessionId: string;
userId: string;
durationMs: number;
resultRef?: string; // S3 URI for large results
resultSummary?: string; // small results inline
errorCode?: string; // present on failure
}
async function emitToolCallCompleted(payload: ToolCallCompletedEvent): Promise {
const entry: PutEventsRequestEntry = {
EventBusName: CUSTOM_BUS_NAME,
Source: 'com.yourapp.mcp',
DetailType: 'ToolCallCompleted',
Detail: JSON.stringify(payload),
Time: new Date(),
};
const { FailedEntryCount, Entries } = await eb.send(new PutEventsCommand({
Entries: [entry],
}));
if (FailedEntryCount && FailedEntryCount > 0) {
// PutEvents partial failure: check each entry's ErrorCode
for (const e of Entries ?? []) {
if (e.ErrorCode) {
console.error({ event: 'eventbridge_put_failed', errorCode: e.ErrorCode, errorMessage: e.ErrorMessage });
}
}
throw new Error(`EventBridge PutEvents: ${FailedEntryCount} entries failed`);
}
}
Event pattern design: source and detail-type first
EventBridge event patterns match incoming events against a JSON pattern. The matching algorithm works like a JSON subset: every field in the pattern must match the same field in the event, and unmentioned fields are ignored. EventBridge evaluates patterns in this precedence order (fastest to slowest): source equality match → detail-type equality match → detail field matches.
Always filter on source and detail-type first. This eliminates most events before EventBridge inspects the detail payload. Patterns that filter only on detail fields must deserialize the entire event payload before matching — they are slower and incur higher processing cost at scale.
// Event pattern for a Lambda target that handles only tool call failures
// Matches: { source: "com.yourapp.mcp", detail-type: "ToolCallCompleted", detail.errorCode: [exists] }
{
"source": ["com.yourapp.mcp"],
"detail-type": ["ToolCallCompleted"],
"detail": {
"errorCode": [{ "exists": true }]
}
}
// Event pattern for a Lambda target that handles specific tool types
{
"source": ["com.yourapp.mcp"],
"detail-type": ["ToolCallCompleted"],
"detail": {
"toolName": ["web_search", "code_execution", "database_query"]
}
}
// Numeric range pattern for duration-based alerting (slow tool calls)
{
"source": ["com.yourapp.mcp"],
"detail-type": ["ToolCallCompleted"],
"detail": {
"durationMs": [{ "numeric": [">=", 30000] }] // tools that took >30 seconds
}
}
// CDK: add a rule to the custom bus
import { Rule, EventPattern } from 'aws-cdk-lib/aws-events';
import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets';
import { EventBus } from 'aws-cdk-lib/aws-events';
const bus = EventBus.fromEventBusName(stack, 'McpBus', 'mcp-tool-events');
new Rule(stack, 'ToolFailureRule', {
eventBus: bus,
eventPattern: {
source: ['com.yourapp.mcp'],
detailType: ['ToolCallCompleted'],
detail: { errorCode: [{ exists: true }] },
} as EventPattern,
targets: [new LambdaFunction(alertFunction, {
deadLetterQueue: alertDlq,
maxEventAge: Duration.hours(1),
retryAttempts: 2,
})],
});
Target dead-letter queues: EventBridge's own retry layer
When EventBridge delivers an event to a target (Lambda, SQS, SNS, API Gateway) and the delivery fails (Lambda returns an error, SQS returns a throttle, API Gateway returns 5xx), EventBridge retries the delivery with exponential backoff. The default retry window is 24 hours and up to 185 retry attempts. Events that exhaust all retries are dropped unless you configure a dead-letter queue on the target itself.
This DLQ is different from an SQS queue's own DLQ. It is a property of the EventBridge target configuration, not the SQS queue configuration. A single SQS queue can have both an EventBridge target DLQ (for events that EventBridge failed to deliver to the SQS queue) and an SQS redrive policy DLQ (for messages that consumers failed to process from the SQS queue). These two DLQs catch different failure modes and should be monitored separately.
import { CfnRule } from 'aws-cdk-lib/aws-events';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { ServicePrincipal } from 'aws-cdk-lib/aws-iam';
// DLQ for EventBridge → SQS target delivery failures
const ebTargetDlq = new Queue(stack, 'EBTargetDLQ', {
queueName: 'eb-delivery-failures-dlq',
retentionPeriod: Duration.days(14),
});
// Grant EventBridge permission to send to the DLQ
ebTargetDlq.grantSendMessages(new ServicePrincipal('events.amazonaws.com'));
// Attach DLQ to the target via CDK
new Rule(stack, 'ToolEventRule', {
eventBus: bus,
eventPattern: { source: ['com.yourapp.mcp'] },
targets: [new LambdaFunction(handlerFn, {
deadLetterQueue: ebTargetDlq, // EventBridge delivery failures go here
maxEventAge: Duration.hours(6), // stop retrying after 6h (not 24h default)
retryAttempts: 3, // max 3 retries (not 185 default)
})],
});
// Note: maxEventAge and retryAttempts on the target limit the retry window.
// Setting maxEventAge prevents events from being retried for 24h when the issue
// is systemic (e.g., Lambda function broken) — fail fast into the DLQ instead.
PutEvents payload limits and large result storage
Each EventBridge event has a maximum size of 256 KB (including all fields: source, detail-type, detail, time, resources, and metadata). A single PutEvents API call accepts up to 10 events per call. These limits are hard — exceeding them returns a validation error at the API level, not at rule evaluation time.
MCP tool results that include file contents, code execution output, or LLM responses can easily exceed 256 KB. The correct pattern: store the full result in S3, then emit an event containing only the S3 URI (and a small result summary for rules that need to match on content). The downstream subscriber retrieves the full result from S3.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const s3 = new S3Client({});
const RESULT_BUCKET = process.env.TOOL_RESULT_BUCKET!;
async function emitToolResult(
toolCallId: string,
result: unknown,
metadata: Omit,
): Promise {
const resultStr = JSON.stringify(result);
const resultBytes = Buffer.byteLength(resultStr, 'utf8');
let resultRef: string | undefined;
let resultSummary: string | undefined;
if (resultBytes > 200_000) {
// Store in S3, reference only the URI in the event
const key = `tool-results/${toolCallId}.json`;
await s3.send(new PutObjectCommand({
Bucket: RESULT_BUCKET,
Key: key,
Body: resultStr,
ContentType: 'application/json',
}));
resultRef = `s3://${RESULT_BUCKET}/${key}`;
resultSummary = resultStr.slice(0, 500) + '…'; // 500 char preview inline
} else {
resultSummary = resultStr;
}
await emitToolCallCompleted({ ...metadata, resultRef, resultSummary });
}
// PutEvents batching for high-frequency tool events
async function batchEmitEvents(entries: PutEventsRequestEntry[]): Promise {
// PutEvents accepts max 10 entries per call
for (let i = 0; i < entries.length; i += 10) {
const batch = entries.slice(i, i + 10);
const { FailedEntryCount, Entries } = await eb.send(new PutEventsCommand({ Entries: batch }));
if (FailedEntryCount) {
for (const e of Entries ?? []) {
if (e.ErrorCode) console.error({ errorCode: e.ErrorCode, errorMessage: e.ErrorMessage });
}
}
}
}
Schema registry: catching contract violations at development time
The EventBridge schema registry automatically discovers event schemas from your event buses (auto-discovery must be enabled per bus) and stores them as JSONSchema Draft 4 documents. You can also publish schemas manually. The registry generates strongly-typed bindings for TypeScript, Java, and Python that you can use in producers and consumers.
For MCP server tool events, publish a schema manually for each detail-type you emit. This creates a stable contract between the MCP server (producer) and downstream subscribers (consumers). If the MCP server changes the event shape, the schema registry validation fails at development time — not in production when a subscriber silently receives an unexpected payload structure.
// Publish a schema for the ToolCallCompleted event
import {
SchemasClient,
CreateSchemaCommand,
UpdateSchemaCommand,
} from '@aws-sdk/client-schemas';
const schemas = new SchemasClient({});
const TOOL_CALL_COMPLETED_SCHEMA = {
openapi: '3.0.0',
info: { title: 'ToolCallCompleted', version: '1.0.0' },
paths: {},
components: {
schemas: {
AWSEvent: {
type: 'object',
required: ['detail-type', 'resources', 'detail', 'id', 'source', 'time', 'region', 'version', 'account'],
properties: {
detail: { $ref: '#/components/schemas/ToolCallCompleted' },
'detail-type': { type: 'string', title: 'detail-type', const: 'ToolCallCompleted' },
source: { type: 'string', title: 'source', const: 'com.yourapp.mcp' },
},
},
ToolCallCompleted: {
type: 'object',
required: ['toolCallId', 'toolName', 'sessionId', 'userId', 'durationMs'],
properties: {
toolCallId: { type: 'string' },
toolName: { type: 'string' },
sessionId: { type: 'string' },
userId: { type: 'string' },
durationMs: { type: 'number' },
resultRef: { type: 'string' },
resultSummary: { type: 'string' },
errorCode: { type: 'string' },
},
},
},
},
};
await schemas.send(new CreateSchemaCommand({
RegistryName: 'mcp-tool-events',
SchemaName: 'com.yourapp.mcp@ToolCallCompleted',
Type: 'OpenApi3',
Content: JSON.stringify(TOOL_CALL_COMPLETED_SCHEMA),
}));
Failure modes reference
| Failure | Symptom | Fix |
|---|---|---|
| Publishing to default event bus | Application events mix with AWS service events; rules match unintended events; payload exposed to account-level consumers | Always create and publish to a custom event bus |
| No DLQ on EventBridge targets | Events exhausting all retries are silently dropped; no record of undelivered events | Attach a dead-letter SQS queue to every EventBridge rule target |
| Event payload exceeds 256 KB | PutEvents returns ValidationException; event not emitted; tool result lost | Store large results in S3; include only S3 URI in event detail |
| PutEvents partial failure ignored | Some events silently not delivered; FailedEntryCount > 0 but application continues without error | Always check FailedEntryCount and iterate Entries[].ErrorCode on every PutEvents response |
| Filtering on detail fields before source/detail-type | EventBridge must deserialize all event payloads to evaluate rule; slower matching; higher cost at scale | Always filter on source and detail-type first; add detail field filters only for further narrowing |
| Default 24h retry window on targets | EventBridge retries a broken Lambda for 24 hours; DLQ fills with stale events that are no longer actionable | Set maxEventAge to 1–6 hours and retryAttempts to 3–5 on each target to fail fast |
| Missing schema for event detail-type | Producer changes event shape; consumers receive unexpected payload; breakage discovered in production | Publish and version schemas in Schema Registry for every detail-type; generate TypeScript bindings |