Guide · AWS Audit & Compliance
MCP Server CloudTrail → EventBridge — real-time security automation, IAM monitoring
CloudTrail automatically forwards all management events to EventBridge at no extra cost — no trail configuration change required. Every CreateBucket, AttachRolePolicy, PutBucketPolicy, and AssumeRole your MCP server or its IAM roles perform is available as an EventBridge event within seconds of the API call, enabling real-time security automation without polling or S3 lag. Three things trip teams up: data events (PutItem, GetObject) are NOT forwarded to EventBridge (only management events flow through — if you need real-time notification of DynamoDB writes by MCP tools, use DynamoDB Streams, not CloudTrail→EventBridge), events arrive at the default event bus in the same region as the API call (if your MCP server operates in us-east-1 and you want to centralize alerts, you must either replicate the default event bus to a central bus using EventBridge cross-region replication or use CloudTrail→CloudWatch Logs→Kinesis to aggregate), and EventBridge event patterns match on the detail object structure, not the raw CloudTrail record (the CloudTrail record is nested under detail in the EventBridge envelope — field paths like detail.requestParameters.roleName require the detail. prefix in pattern rules).
TL;DR
CloudTrail management events flow to EventBridge automatically. Create an EventBridge rule on the aws.cloudtrail source with detail-type: "AWS API Call via CloudTrail". Filter by detail.eventName and detail.userIdentity.principalId. Target a Lambda or SNS topic. For IAM privilege escalation detection: match on AttachRolePolicy, PutRolePolicy, CreateAccessKey where the principal contains your MCP role name. Build a deny-list of APIs your MCP server should never call.
The CloudTrail → EventBridge automatic integration
AWS automatically sends all CloudTrail management events to EventBridge via the default event bus in each region. No trail configuration is needed — the integration exists regardless of whether you have a trail. The caveat is that the trail only enables persistence (S3 delivery) and Insights; for real-time EventBridge consumption, you do not need a trail at all, just an EventBridge rule matching source: ["aws.cloudtrail"].
The EventBridge event structure wraps the CloudTrail record:
{
"version": "0",
"id": "6a7e8feb-b491-4cf7-a9f1-bf3703467718",
"source": "aws.cloudtrail",
"account": "123456789012",
"time": "2026-09-19T14:23:41Z",
"region": "us-east-1",
"resources": [],
"detail-type": "AWS API Call via CloudTrail",
"detail": {
// This is the full CloudTrail event record:
"eventVersion": "1.08",
"eventTime": "2026-09-19T14:23:41Z",
"eventSource": "iam.amazonaws.com",
"eventName": "AttachRolePolicy",
"awsRegion": "us-east-1",
"sourceIPAddress": "10.0.1.45",
"userAgent": "aws-sdk-nodejs/3.600.0",
"requestParameters": {
"roleName": "mcp-server-task-role",
"policyArn": "arn:aws:iam::aws:policy/AdministratorAccess"
},
"userIdentity": {
"type": "AssumedRole",
"principalId": "AROAEXAMPLEID:mcp-server-task",
"arn": "arn:aws:sts::123456789012:assumed-role/mcp-server-task-role/mcp-server-task",
"accountId": "123456789012"
}
}
}
The detail.requestParameters and detail.userIdentity are the same fields as in the raw CloudTrail record. EventBridge pattern matching occurs on these paths.
Event pattern design for MCP server security monitoring
Define a deny-list of API calls that the MCP server role should never make under normal operation. Any occurrence is worth alerting on:
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
import * as sns from "aws-cdk-lib/aws-sns";
const securityAlertTopic = new sns.Topic(this, "SecurityAlerts", {
topicName: "mcp-security-alerts",
});
// Rule 1: IAM privilege escalation — MCP server should NEVER modify IAM
new events.Rule(this, "IamEscalationRule", {
ruleName: "mcp-iam-escalation-detect",
description: "Alert when MCP server role modifies IAM — indicates compromise or misconfiguration",
eventPattern: {
source: ["aws.cloudtrail"],
detailType: ["AWS API Call via CloudTrail"],
detail: {
eventSource: ["iam.amazonaws.com"],
eventName: [
"AttachRolePolicy",
"DetachRolePolicy",
"PutRolePolicy",
"DeleteRolePolicy",
"CreateRole",
"DeleteRole",
"CreateAccessKey",
"DeleteAccessKey",
"UpdateAccessKey",
"CreatePolicy",
"CreatePolicyVersion",
"SetDefaultPolicyVersion",
],
userIdentity: {
principalId: [{ prefix: "AROAEXAMPLEID:" }], // MCP server role principal ID prefix
},
},
},
targets: [new targets.SnsTopic(securityAlertTopic)],
});
// Rule 2: S3 bucket policy modification — MCP server should only read/write objects
new events.Rule(this, "S3PolicyModRule", {
ruleName: "mcp-s3-policy-modification",
eventPattern: {
source: ["aws.cloudtrail"],
detailType: ["AWS API Call via CloudTrail"],
detail: {
eventSource: ["s3.amazonaws.com"],
eventName: [
"PutBucketPolicy",
"DeleteBucketPolicy",
"PutBucketAcl",
"PutBucketPublicAccessBlock",
"DeletePublicAccessBlock",
],
userIdentity: {
sessionContext: {
sessionIssuer: {
arn: [{ suffix: "mcp-server-task-role" }],
},
},
},
},
},
targets: [new targets.SnsTopic(securityAlertTopic)],
});
// Rule 3: AssumeRole to non-approved roles (credential vending detection)
new events.Rule(this, "AssumeRoleDenyList", {
ruleName: "mcp-assume-role-audit",
eventPattern: {
source: ["aws.cloudtrail"],
detailType: ["AWS API Call via CloudTrail"],
detail: {
eventSource: ["sts.amazonaws.com"],
eventName: ["AssumeRole"],
userIdentity: {
sessionContext: {
sessionIssuer: {
arn: [{ suffix: "mcp-server-task-role" }],
},
},
},
},
},
targets: [new targets.SnsTopic(securityAlertTopic)],
});
// Rule 4: ConsoleLogin — MCP server task role should never log in to the console
new events.Rule(this, "ConsoleLoginRule", {
ruleName: "mcp-console-login-detect",
eventPattern: {
source: ["aws.cloudtrail"],
detailType: ["AWS Console Sign In via CloudTrail"],
detail: {
userIdentity: {
arn: [{ suffix: "mcp-server-task-role" }],
},
},
},
targets: [new targets.SnsTopic(securityAlertTopic)],
});
Lambda handler: enriched security alert with context
A raw CloudTrail event is not immediately actionable. A Lambda enrichment handler adds the current IAM role policy state, recent similar events, and a recommended action:
import { IAMClient, ListAttachedRolePoliciesCommand } from "@aws-sdk/client-iam";
const iam = new IAMClient({});
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL!;
export async function handler(event: {
detail: {
eventName: string;
eventSource: string;
eventTime: string;
userIdentity: { principalId: string; arn: string };
requestParameters: Record;
sourceIPAddress: string;
errorCode?: string;
};
}) {
const detail = event.detail;
const roleName = extractRoleName(detail.userIdentity.arn);
// Enrich: fetch current policies on the role (for IAM events, show what policies now exist)
let currentPolicies: string[] = [];
if (detail.eventSource === "iam.amazonaws.com" && roleName) {
try {
const resp = await iam.send(new ListAttachedRolePoliciesCommand({ RoleName: roleName }));
currentPolicies = (resp.AttachedPolicies ?? []).map(p => p.PolicyArn ?? "");
} catch {
currentPolicies = ["(failed to retrieve)"];
}
}
const isFailed = !!detail.errorCode;
const color = isFailed ? "warning" : "danger";
await fetch(SLACK_WEBHOOK, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `🚨 *Security event: ${detail.eventName}* ${isFailed ? "(FAILED)" : "(SUCCEEDED)"}`,
attachments: [{
color,
fields: [
{ title: "API call", value: `${detail.eventSource} / ${detail.eventName}`, short: true },
{ title: "Status", value: detail.errorCode ?? "Success", short: true },
{ title: "Principal", value: detail.userIdentity.principalId, short: false },
{ title: "Source IP", value: detail.sourceIPAddress, short: true },
{ title: "Time", value: detail.eventTime, short: true },
{ title: "Request params", value: JSON.stringify(detail.requestParameters, null, 2), short: false },
...(currentPolicies.length > 0 ? [{
title: "Current attached policies on role",
value: currentPolicies.join("\n"),
short: false,
}] : []),
],
}],
}),
});
}
function extractRoleName(arn: string): string | null {
// arn:aws:sts::account:assumed-role/role-name/session
const match = arn.match(/assumed-role\/([^/]+)\//);
return match?.[1] ?? null;
}
MCP server self-monitoring its own IAM usage
An MCP server can monitor its own IAM footprint by subscribing to CloudTrail events about its own role. The pattern: instrument the startup routine to check that the role's attached policies match an expected list; subscribe via EventBridge to any modification events on the role; if a policy is added unexpectedly (via a runaway tool call or injection), automatically detach it and page the operator:
// In the MCP server startup health check:
import { IAMClient, ListAttachedRolePoliciesCommand } from "@aws-sdk/client-iam";
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
const EXPECTED_POLICIES = new Set([
"arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess", // example — scope to minimum required
"arn:aws:iam::123456789012:policy/mcp-s3-output-write",
]);
export async function checkIamIntegrity(): Promise {
const sts = new STSClient({});
const iam = new IAMClient({});
const { Arn } = await sts.send(new GetCallerIdentityCommand({}));
const roleName = Arn?.match(/assumed-role\/([^/]+)\//)?.[1];
if (!roleName) return;
const { AttachedPolicies } = await iam.send(
new ListAttachedRolePoliciesCommand({ RoleName: roleName })
);
const attached = new Set(AttachedPolicies?.map(p => p.PolicyArn!) ?? []);
const unexpected = [...attached].filter(p => !EXPECTED_POLICIES.has(p));
if (unexpected.length > 0) {
// Emit a CloudWatch custom metric — triggers alarm → SNS → ops page
console.error(JSON.stringify({
level: "CRITICAL",
event: "unexpected_iam_policy",
role: roleName,
unexpected_policies: unexpected,
}));
// The CloudWatch Logs metric filter catches level=CRITICAL and increments
// a custom metric that triggers an alarm immediately.
throw new Error(`Unexpected IAM policies detected: ${unexpected.join(", ")}`);
}
}
Cross-region event aggregation
CloudTrail management events arrive at the EventBridge default event bus in the region where the API call was made. An MCP server deployed in us-east-1 and eu-west-1 generates events on two separate regional default buses. To aggregate for a single monitoring Lambda:
// Create a central custom event bus in the primary region
const centralBus = new events.EventBus(this, "CentralSecurityBus", {
eventBusName: "mcp-security-central",
});
// In each secondary region, create a cross-region rule that forwards CloudTrail events
// (CDK cross-region EventBridge replication requires a CrossRegionEventBusPolicy construct)
// Simpler approach: use CloudTrail multi-region trail + CloudWatch Logs delivery,
// then subscribe a Kinesis Data Firehose or Lambda to the CloudWatch Logs subscription.
// Alternative — CloudTrail org trail with CloudWatch Logs delivery to central log group:
const trailLogGroup = new logs.LogGroup(this, "TrailLogs", {
logGroupName: "/aws/cloudtrail/mcp-audit",
retention: logs.RetentionDays.THREE_MONTHS,
});
trail.logAllCloudWatchLogEvents(trailLogGroup);
// Metric filter on CloudWatch Logs for specific event names
new logs.MetricFilter(this, "IamWriteFilter", {
logGroup: trailLogGroup,
filterPattern: logs.FilterPattern.literal(
'{ $.eventSource = "iam.amazonaws.com" && $.userIdentity.sessionContext.sessionIssuer.arn = "*mcp*" }'
),
metricNamespace: "MCP/CloudTrailSecurity",
metricName: "IamWriteByMcpRole",
metricValue: "1",
defaultValue: 0,
});
Failure modes
| Symptom | Root cause | Fix |
|---|---|---|
| EventBridge rule not matching CloudTrail events | Pattern references top-level fields instead of detail.* paths (CloudTrail record is nested under detail) |
Use detail.eventName, detail.eventSource, detail.userIdentity.principalId — not top-level eventName |
| No events reaching EventBridge for a specific API | The API is a data event (PutItem, GetObject) — data events are not forwarded to EventBridge | Use DynamoDB Streams or S3 Event Notifications for data-level real-time events; CloudTrail→EventBridge only covers management events |
| EventBridge rule fires on events from other accounts | Default event bus receives events from all accounts in the organization if org policy is open | Add account: ["123456789012"] to event pattern to scope to specific account; or use dedicated cross-account bus with explicit resource policy |
| Lambda not invoked despite matching CloudTrail event | Lambda resource policy missing EventBridge invoke permission | Add lambda:InvokeFunction permission for events.amazonaws.com principal with aws:SourceArn scoped to the rule ARN |
| Alert fires for failed API calls (errorCode present) that don't need alerting | Pattern does not filter out errorCode — even denied IAM escalation attempts fire the rule |
Add errorCode: [{"anything-but": ["AccessDenied", "AccessDeniedException", "UnauthorizedOperation"]}] to only alert on successful modifications; or log failures at a lower severity in the Lambda handler |