Guide · AWS Audit & Compliance

MCP Server CloudTrail Data Events — S3 and DynamoDB audit logging

CloudTrail data events record every S3 object-level operation (GetObject, PutObject, DeleteObject) and every DynamoDB item-level operation (GetItem, PutItem, DeleteItem, UpdateItem, Query, Scan) performed by your MCP tools — giving you a complete tamper-evident log of which tool call touched which data at exactly what time. Three things trip teams up: data events are not included in the free first-trail allowance (management events like CreateBucket or CreateTable are free; data events cost $0.10 per 100,000 events and must be explicitly enabled), the default advanced event selector records both reads and writes (a busy MCP server doing frequent DynamoDB reads will generate millions of events per day — always scope selectors to writes only unless you specifically need read audit), and the trail must be in the same region as the resource (or use a multi-region trail) otherwise data events from that resource simply never appear in the log.

TL;DR

Create a CloudTrail trail with IsMultiRegionTrail: true. Add an advanced event selector targeting your MCP tool's S3 bucket and DynamoDB table. Set readWriteType: WRITE to capture mutations only. Deliver to an S3 bucket with a restrictive policy (CloudTrail service principal, deny delete). Every PutItem, PutObject, and DeleteItem your tools execute will appear in the log within 15 minutes. Query with Athena or CloudTrail Lake.

Management events vs data events — the billing boundary

CloudTrail splits all API calls into two categories. Management events are control-plane operations: creating a DynamoDB table, attaching an IAM policy, creating a Lambda function. The first trail in each region records management events at no charge. Data events are data-plane operations: reading or writing an S3 object, reading or writing a DynamoDB item, invoking a Lambda function. Data events require explicit opt-in and cost $0.10 per 100,000 events regardless of how many trails you have.

For a typical MCP server making 10 DynamoDB writes per tool call at 50 tool calls per minute: 500 data events per minute × 60 × 24 = 720,000 events per day → approximately $0.72/day or $22/month. If you also record DynamoDB reads (GetItem for session lookup, Query for context retrieval) the volume can be 5-10× higher. Scope to writes unless compliance requirements mandate read audit.

# Cost estimate: DynamoDB write-only for a medium MCP server
# 50 tool calls/min × 10 writes/call = 500 writes/min
# 500 × 60 × 24 × 30 = 21,600,000 events/month
# 21,600,000 / 100,000 × $0.10 = $21.60/month

# Compare: DynamoDB read+write (session lookup + writes)
# 50 tool calls/min × (5 reads + 10 writes) = 750 events/min
# 750 × 60 × 24 × 30 = 32,400,000 events/month
# 32,400,000 / 100,000 × $0.10 = $32.40/month

Creating a trail with data events via CDK

The critical construction order: create the S3 delivery bucket first (CloudTrail needs to verify it can write to the bucket before the trail is created), add the CloudTrail service principal bucket policy, then create the trail, then add event selectors. Attempting to add event selectors at trail construction time in CDK sometimes silently fails for DynamoDB; add them as a separate CfnTrail.AdvancedEventSelectorsProperty after the trail resource.

import * as cdk from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as cloudtrail from "aws-cdk-lib/aws-cloudtrail";
import * as iam from "aws-cdk-lib/aws-iam";

export class McpAuditTrailStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string) {
    super(scope, id);

    // Delivery bucket — CloudTrail writes compressed JSON logs here
    const auditBucket = new s3.Bucket(this, "AuditLogs", {
      bucketName: `mcp-cloudtrail-audit-${this.account}`,
      encryption: s3.BucketEncryption.S3_MANAGED,
      versioned: true,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      lifecycleRules: [
        {
          // Keep logs accessible for 90 days (active audit window)
          transitions: [{ storageClass: s3.StorageClass.INFREQUENT_ACCESS, transitionAfter: cdk.Duration.days(90) }],
          expiration: cdk.Duration.days(365),
        },
      ],
    });

    // CloudTrail requires explicit bucket policy allowing its service principal to write
    auditBucket.addToResourcePolicy(
      new iam.PolicyStatement({
        principals: [new iam.ServicePrincipal("cloudtrail.amazonaws.com")],
        actions: ["s3:GetBucketAcl"],
        resources: [auditBucket.bucketArn],
        conditions: {
          StringEquals: { "AWS:SourceArn": `arn:aws:cloudtrail:${this.region}:${this.account}:trail/mcp-audit-trail` },
        },
      })
    );
    auditBucket.addToResourcePolicy(
      new iam.PolicyStatement({
        principals: [new iam.ServicePrincipal("cloudtrail.amazonaws.com")],
        actions: ["s3:PutObject"],
        resources: [`${auditBucket.bucketArn}/AWSLogs/${this.account}/*`],
        conditions: {
          StringEquals: {
            "s3:x-amz-acl": "bucket-owner-full-control",
            "AWS:SourceArn": `arn:aws:cloudtrail:${this.region}:${this.account}:trail/mcp-audit-trail`,
          },
        },
      })
    );

    // Multi-region trail — captures data events from us-east-1, us-west-2, etc.
    const trail = new cloudtrail.Trail(this, "McpAuditTrail", {
      trailName: "mcp-audit-trail",
      bucket: auditBucket,
      isMultiRegionTrail: true,
      includeGlobalServiceEvents: true,  // IAM events (no region)
      enableFileValidation: true,        // SHA-256 digest chain for log tamper detection
      managementEvents: cloudtrail.ReadWriteType.WRITE_ONLY, // CREATE, DELETE, PUT only
    });

    // S3 data events: bucket prefix for tool uploads/output storage
    trail.addS3EventSelector(
      [{ bucket: /* your tool-output bucket reference */ null as any }],
      { readWriteType: cloudtrail.ReadWriteType.WRITE_ONLY }
    );
  }
}

For DynamoDB data events, CDK's Trail construct does not expose a first-class DynamoDB selector method as of CDK v2. Use an CfnTrail escape hatch or an L1 aws-cdk-lib.aws_cloudtrail.CfnTrail with advancedEventSelectors directly:

import { aws_cloudtrail as cfnTrail } from "aws-cdk-lib";

// Add DynamoDB data events via L1 escape hatch after trail creation
const cfn = trail.node.defaultChild as cfnTrail.CfnTrail;
cfn.addPropertyOverride("AdvancedEventSelectors", [
  {
    Name: "DynamoDB-WriteOnly",
    FieldSelectors: [
      { Field: "eventCategory", Equals: ["Data"] },
      { Field: "resources.type", Equals: ["AWS::DynamoDB::Table"] },
      // Scope to specific tables — remove for all tables in account
      { Field: "resources.ARN", StartsWith: [
        `arn:aws:dynamodb:${stack.region}:${stack.account}:table/mcp-sessions`,
        `arn:aws:dynamodb:${stack.region}:${stack.account}:table/mcp-toolcalls`,
      ]},
      // Write operations only: PutItem, UpdateItem, DeleteItem, TransactWriteItems, BatchWriteItem
      { Field: "readOnly", Equals: ["false"] },
    ],
  },
  {
    Name: "S3-WriteOnly",
    FieldSelectors: [
      { Field: "eventCategory", Equals: ["Data"] },
      { Field: "resources.type", Equals: ["AWS::S3::Object"] },
      { Field: "resources.ARN", StartsWith: [
        `arn:aws:s3:::mcp-tool-outputs/`,
        `arn:aws:s3:::mcp-session-artifacts/`,
      ]},
      { Field: "readOnly", Equals: ["false"] },
    ],
  },
]);

What each CloudTrail data event record contains

Each event is a JSON object delivered as a gzipped file to S3. The fields most useful for MCP tool audit are:

{
  "eventTime": "2026-09-19T14:23:41Z",      // UTC timestamp
  "eventName": "PutItem",                    // DynamoDB API call name
  "eventSource": "dynamodb.amazonaws.com",
  "awsRegion": "us-east-1",
  "sourceIPAddress": "10.0.1.45",           // ECS task private IP
  "userAgent": "aws-sdk-nodejs/3.600.0",
  "requestParameters": {
    "tableName": "mcp-sessions",
    "item": {
      "session_id": { "S": "sess_abc123" },
      "tool_name": { "S": "list_files" },
      // NOTE: actual item data is redacted in CloudTrail — only key attributes shown
    },
    "conditionExpression": "attribute_not_exists(pk)"
  },
  "responseElements": null,                 // DynamoDB write responses are null
  "requestID": "ABCDEF1234567890",
  "eventID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "userIdentity": {
    "type": "AssumedRole",
    "principalId": "AROAEXAMPLEID:mcp-server-task",
    "arn": "arn:aws:sts::123456789012:assumed-role/mcp-server-task-role/mcp-server-task",
    "accountId": "123456789012",
    "sessionContext": {
      "sessionIssuer": {
        "type": "Role",
        "principalId": "AROAEXAMPLEID",
        "arn": "arn:aws:iam::123456789012:role/mcp-server-task-role",
        "accountId": "123456789012",
        "userName": "mcp-server-task-role"
      }
    }
  },
  "resources": [
    {
      "ARN": "arn:aws:dynamodb:us-east-1:123456789012:table/mcp-sessions",
      "accountId": "123456789012",
      "type": "AWS::DynamoDB::Table"
    }
  ]
}

Key observation: DynamoDB item values are NOT logged in full. CloudTrail records the table name and key attributes (from requestParameters.key for GetItem/UpdateItem/DeleteItem), but attribute values in requestParameters.item are omitted for privacy. If you need full item-level audit with values, use DynamoDB Streams with a Lambda function writing to CloudWatch Logs or Kinesis Data Firehose instead.

S3 data events log the object key but not the object content. The requestParameters.key field contains the full S3 key (e.g., tool-outputs/sess_abc123/2026-09-19T14:23:41Z-result.json), and requestParameters.bucketName contains the bucket.

Scoping data events to write operations only

The readOnly field selector is the single most important cost-control lever. CloudTrail sets readOnly: true for operations that do not mutate state (GetItem, GetObject, HeadObject, Query, Scan, DescribeTable) and readOnly: false for mutations (PutItem, PutObject, DeleteItem, DeleteObject, UpdateItem, BatchWriteItem, TransactWriteItems, RestoreTableFromBackup).

# AWS CLI: update existing trail to add write-only DynamoDB selector
aws cloudtrail put-event-selectors \
  --trail-name mcp-audit-trail \
  --advanced-event-selectors '[
    {
      "Name": "DynamoDB-WriteOnly-SpecificTables",
      "FieldSelectors": [
        {"Field": "eventCategory", "Equals": ["Data"]},
        {"Field": "resources.type", "Equals": ["AWS::DynamoDB::Table"]},
        {"Field": "resources.ARN", "StartsWith": [
          "arn:aws:dynamodb:us-east-1:123456789012:table/mcp-sessions",
          "arn:aws:dynamodb:us-east-1:123456789012:table/mcp-toolcalls"
        ]},
        {"Field": "readOnly", "Equals": ["false"]}
      ]
    },
    {
      "Name": "S3-WriteOnly-ToolOutputBucket",
      "FieldSelectors": [
        {"Field": "eventCategory", "Equals": ["Data"]},
        {"Field": "resources.type", "Equals": ["AWS::S3::Object"]},
        {"Field": "resources.ARN", "StartsWith": [
          "arn:aws:s3:::mcp-tool-outputs/"
        ]},
        {"Field": "readOnly", "Equals": ["false"]}
      ]
    }
  ]'

You can have at most 500 advanced event selectors per trail, and each selector can have at most 500 field selector values. For organizations running many MCP tenants with separate DynamoDB tables, prefer a wildcard ARN prefix (e.g., StartsWith: ["arn:aws:dynamodb:us-east-1:123456789012:table/mcp-"]) rather than enumerating individual tables.

MCP tool audit log pattern: correlating tool calls to data events

CloudTrail data events capture the IAM principal and the source IP but do not capture application-level context like the MCP session ID or tool call ID. To correlate a CloudTrail event back to a specific tool call invocation, use one of two approaches:

Session tag propagation: When the ECS task's IAM role assumes a per-session role (or uses the same role), the userIdentity.sessionContext.sessionIssuer and userIdentity.principalId fields embed the role session name. If you call sts:AssumeRole with RoleSessionName: toolCallId for each tool invocation, the CloudTrail record for every DynamoDB write made during that tool call will contain the tool call ID in userIdentity.principalId (format: AROAEXAMPLEID:toolCallId). This enables exact reconstruction of which tool call caused which DynamoDB write.

DynamoDB attribute correlation: If your MCP server writes a tool_call_id attribute to every DynamoDB record it creates, and CloudTrail records the item key — you can join on the key to retrieve the full item from DynamoDB (which has the tool call ID), cross-referencing with your application log. Not as clean but avoids per-call role assumption overhead.

// Pattern: embed tool_call_id in the DynamoDB partition key so CloudTrail
// requestParameters.key reveals the tool call ID without needing sts:AssumeRole
//
// Table schema: pk = "toolcall#", sk = "output#"
// CloudTrail will log: requestParameters.key.pk = "toolcall#tc_abc123"
// This allows direct correlation without STS overhead.

import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";

const ddb = new DynamoDBClient({});

export async function recordToolCallOutput(
  toolCallId: string,
  sessionId: string,
  toolName: string,
  outputSummary: string
) {
  await ddb.send(new PutItemCommand({
    TableName: "mcp-toolcalls",
    Item: marshall({
      pk: `toolcall#${toolCallId}`,          // embedded in CloudTrail key log
      sk: `output#${new Date().toISOString()}`,
      session_id: sessionId,
      tool_name: toolName,
      output_summary: outputSummary,         // NOT logged in CloudTrail — value redacted
      ttl: Math.floor(Date.now() / 1000) + 86400 * 90,
    }),
    ConditionExpression: "attribute_not_exists(pk)", // idempotency
  }));
}

Log file delivery timing and the 15-minute latency

CloudTrail does not deliver log files in real time. The service batches events and delivers compressed log files to S3 approximately every 15 minutes. The eventTime in each record is accurate to the second, but the file itself may not appear in S3 until 15 minutes after the last event in the batch. For security alerting use cases that require sub-minute detection, use CloudTrail → EventBridge (management events only — data events are not forwarded to EventBridge) or CloudTrail → CloudWatch Logs delivery combined with a metric filter and alarm.

CloudTrail delivers files to s3://<bucket>/AWSLogs/<account-id>/CloudTrail/<region>/YYYY/MM/DD/ with filenames of the form <account-id>_CloudTrail_<region>_<timestamp>_<random>.json.gz. Each file contains an array of events under the key "Records".

Failure modes and common mistakes

SymptomRoot causeFix
DynamoDB operations not appearing in CloudTrail Trail created without data event selectors (management events only by default) Add advanced event selector with resources.type: AWS::DynamoDB::Table and re-verify with a test PutItem
Data events appear for some regions but not others Trail is single-region (default) and the DynamoDB table is in a different region Set IsMultiRegionTrail: true or create a separate trail in each region where MCP tables exist
S3 data events not logging — bucket in same account but different region Single-region trail only covers the region it was created in Use multi-region trail or create a regional trail in the same region as the S3 bucket
CloudTrail log delivery failing with AccessDenied S3 bucket policy missing s3:PutObject for CloudTrail service principal with SourceArn condition Add both s3:GetBucketAcl and s3:PutObject statements with correct AWS:SourceArn matching the trail ARN
Unexpected high CloudTrail bill readWriteType not set — defaulting to ALL (reads + writes) Add {"Field": "readOnly", "Equals": ["false"]} to all data event selectors
Cannot correlate CloudTrail event to specific tool call All tool calls use the same IAM role with no session name differentiation Embed tool call ID in DynamoDB partition key prefix, or use per-call STS AssumeRole with RoleSessionName=toolCallId
Log integrity validation failing (SHA-256 digest mismatch) Someone deleted or modified a log file in the S3 bucket Enable S3 Object Lock (WORM mode) on the audit bucket; use MFA Delete; restrict s3:DeleteObject to no principal except a break-glass role

Querying data events with Athena

CloudTrail logs delivered to S3 can be queried with Athena after creating a table over the S3 prefix. AWS provides a pre-built Glue Data Catalog table via the CloudTrail console ("Create Athena table" button), but you can also create it manually to customize partition projection:

-- Create Athena table with partition projection for cost-efficient queries
CREATE EXTERNAL TABLE cloudtrail_mcp_audit (
  eventversion STRING,
  useridentity STRUCT<
    type: STRING,
    principalid: STRING,
    arn: STRING,
    accountid: STRING,
    sessioncontext: STRUCT>
  >,
  eventtime STRING,
  eventsource STRING,
  eventname STRING,
  awsregion STRING,
  sourceipaddress STRING,
  useragent STRING,
  requestparameters STRING,
  responseelements STRING,
  requestid STRING,
  eventid STRING,
  resources ARRAY>,
  eventtype STRING,
  readonly STRING
)
PARTITIONED BY (region STRING, year STRING, month STRING, day STRING)
ROW FORMAT SERDE 'com.amazon.emr.hive.serde.CloudTrailSerde'
STORED AS INPUTFORMAT 'com.amazon.emr.cloudtrail.CloudTrailInputFormat'
OUTPUTFORMAT 'org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat'
LOCATION 's3://mcp-cloudtrail-audit-123456789012/AWSLogs/123456789012/CloudTrail/'
TBLPROPERTIES (
  'projection.enabled' = 'true',
  'projection.region.type' = 'enum',
  'projection.region.values' = 'us-east-1,us-west-2,eu-west-1',
  'projection.year.type' = 'integer',
  'projection.year.range' = '2026,2030',
  'projection.month.type' = 'integer',
  'projection.month.range' = '1,12',
  'projection.month.digits' = '2',
  'projection.day.type' = 'integer',
  'projection.day.range' = '1,31',
  'projection.day.digits' = '2',
  'storage.location.template' = 's3://mcp-cloudtrail-audit-123456789012/AWSLogs/123456789012/CloudTrail/${region}/${year}/${month}/${day}/'
);

-- Query: all DynamoDB write events from MCP server in last 7 days
SELECT
  eventtime,
  eventname,
  useridentity.principalid AS principal,
  useridentity.arn AS role_arn,
  sourceipaddress,
  requestparameters
FROM cloudtrail_mcp_audit
WHERE region = 'us-east-1'
  AND year = '2026'
  AND month = '09'
  AND eventsource = 'dynamodb.amazonaws.com'
  AND readonly = 'false'
  AND eventname IN ('PutItem', 'UpdateItem', 'DeleteItem', 'TransactWriteItems', 'BatchWriteItem')
ORDER BY eventtime DESC
LIMIT 1000;