AWS S3 · 2026-10-17 · S3 Advanced Features arc

AWS S3 Advanced Patterns for MCP Servers: Event-Driven Pipelines, Data Transformation, Multi-Tenant Isolation, and Cross-Region DR

Most MCP servers interact with S3 as a simple file store — put an object, get an object, done. The servers that operate reliably at scale go further: they use S3 Event Notifications to trigger tool workflows the moment a file lands, S3 Object Lambda to intercept and clean data before it reaches an LLM, S3 Access Points to enforce strict per-tenant namespace isolation without a sprawling bucket policy, and S3 Cross-Region Replication to ensure a DR region always has the data it needs to serve traffic the instant a Global Accelerator traffic dial switches over. This guide synthesises four S3 advanced feature areas into three structural patterns for MCP teams. Pattern 1 covers event-driven MCP tool pipelines — choosing among SQS, SNS, Lambda, and EventBridge notification destinations, configuring fan-out for multi-tool reactions, handling the at-least-once delivery guarantee with idempotent tool handlers, and monitoring the event pipeline for silent failures — drawing on S3 Event Notifications for MCP Servers. Pattern 2 covers data transformation and multi-tenant isolation — the S3 Object Lambda architecture for PII redaction and format conversion, the write-back pattern for large objects exceeding the 6 MB inline limit, tenant-specific Access Point policies that eliminate monolithic bucket policy sprawl, VPC-only access points for private MCP deployments, and how Object Lambda and Access Points combine for a complete per-tenant transformation pipeline — synthesised from S3 Object Lambda for MCP Servers and S3 Access Points for Multi-Tenant MCP Servers. Pattern 3 covers replication, failover, and replication-lag monitoring — CRR vs SRR for MCP data categories, Replication Time Control for SLA-bounded DR, bidirectional replication rules with loop prevention for active-active S3 architectures, Batch Replication for seeding existing objects to a new region, and AliveMCP probe strategy for detecting replication lag before a GA failover exposes a stale replica — drawn from S3 Replication for MCP Servers.

TL;DR

Pattern 1 — Event-Driven MCP Tool Pipelines with S3 Event Notifications

Why upload events are the natural entry point for MCP tool orchestration

Many MCP tool workflows are fundamentally file-triggered: a user uploads a PDF for summarisation, a CI pipeline deposits a test artifact for analysis, a batch ETL job writes a result file to a known prefix for ingestion. Polling S3 for new objects — scanning the bucket every N seconds — wastes Lambda invocations on a known-empty bucket between uploads, adds latency proportional to the polling interval, and misses objects uploaded in bursts that arrive faster than the poll cadence. S3 Event Notifications convert the upload moment into a push signal delivered to your processing infrastructure in under one second, with no polling.

The four notification destinations each make different tradeoffs that align with different MCP tool characteristics. Understanding the tradeoffs is the first design decision; the implementation details follow from it.

DestinationDelivery latencyFiltering powerFan-outBest MCP use case
SQS (standard queue) ~1s median, 15s p99 Prefix + suffix only (S3 level) One queue per bucket notification configuration Durable work queue for slow tools (>3s); handles upload spikes via buffering
SNS <1s Prefix + suffix only Fan-out to multiple SQS + Lambda subscriptions in one notification One upload triggering multiple MCP tools (parse + index + notify)
Lambda <1s (warm), 1–3s (cold) Prefix + suffix only One function per bucket notification configuration Simple, fast reactions (<3s); avoid for high-upload-volume buckets
EventBridge <1s Object key, size, ETag, storage class, metadata Multiple rules → multiple targets per bucket (unlimited) New architectures; routing by file size or type; cross-account delivery

SQS as a durable MCP tool work queue

The SQS pattern is the right default for any MCP tool that takes longer than 3 seconds to execute — document parsing, LLM inference, image analysis, code compilation. The queue acts as a shock absorber: if users upload 50 files in a burst, all 50 events queue immediately while your MCP worker processes them at the rate it can sustain. Without SQS, a Lambda burst would launch 50 concurrent function instances, each invoking a downstream LLM API, simultaneously exhausting your API rate limit quota and returning partial failures.

SQS visibility timeout is the critical sizing parameter. When a worker receives a message, SQS hides that message from other workers for the duration of the visibility timeout — if the worker dies or takes too long, the message reappears for retry. Set the visibility timeout to at least 6× your median tool execution time. If document parsing takes 45 seconds, set 300 seconds. The maxReceiveCount: 3 dead-letter queue configuration catches events that fail three consecutive times and routes them to the DLQ for inspection without loss.

import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sqs from 'aws-cdk-lib/aws-sqs';
import * as s3n from 'aws-cdk-lib/aws-s3-notifications';

const dlq = new sqs.Queue(this, 'McpToolDlq', {
  retentionPeriod: cdk.Duration.days(14),
  encryption: sqs.QueueEncryption.SQS_MANAGED,
});

const processingQueue = new sqs.Queue(this, 'McpToolQueue', {
  visibilityTimeout: cdk.Duration.seconds(300),  // 6x median tool execution
  retentionPeriod: cdk.Duration.days(4),
  deadLetterQueue: { queue: dlq, maxReceiveCount: 3 },
  encryption: sqs.QueueEncryption.SQS_MANAGED,
});

const uploadBucket = new s3.Bucket(this, 'McpUploadBucket', {
  encryption: s3.BucketEncryption.S3_MANAGED,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  versioned: true,
});

// Separate notification configurations per suffix — S3 can't combine them
uploadBucket.addEventNotification(
  s3.EventType.OBJECT_CREATED,
  new s3n.SqsDestination(processingQueue),
  { prefix: 'uploads/', suffix: '.pdf' },
);
uploadBucket.addEventNotification(
  s3.EventType.OBJECT_CREATED,
  new s3n.SqsDestination(processingQueue),
  { prefix: 'uploads/', suffix: '.docx' },
);

EventBridge for routing by file size, type, and cross-account fan-out

EventBridge is the right choice for new MCP event architectures because its filter language operates on the full event body — including object size, storage class, ETag, and user-defined metadata — rather than just the key prefix and suffix. This enables routing patterns impossible with native S3 notifications:

const bucket = new s3.Bucket(this, 'McpDataBucket', {
  eventBridgeEnabled: true,  // enables S3 → EventBridge delivery
  encryption: s3.BucketEncryption.S3_MANAGED,
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
});

import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';

// Large files (>10MB) → slow-processing SQS queue
new events.Rule(this, 'LargeFileRule', {
  eventPattern: {
    source: ['aws.s3'],
    detailType: ['Object Created'],
    detail: {
      bucket: { name: [bucket.bucketName] },
      object: {
        key: [{ prefix: 'uploads/' }],
        size: [{ numeric: ['>=', 10_000_000] }],
      },
    },
  },
  targets: [new targets.SqsQueue(slowProcessingQueue)],
});

// Small files (<10MB) → fast-processing SQS queue
new events.Rule(this, 'SmallFileRule', {
  eventPattern: {
    source: ['aws.s3'],
    detailType: ['Object Created'],
    detail: {
      bucket: { name: [bucket.bucketName] },
      object: {
        key: [{ prefix: 'uploads/' }],
        size: [{ numeric: ['<', 10_000_000] }],
      },
    },
  },
  targets: [new targets.SqsQueue(fastProcessingQueue)],
});

EventBridge also supports cross-account event buses — you can route S3 events from your data-lake account directly to MCP tool queues in separate application accounts without intermediate Lambda glue functions. For an enterprise MCP architecture where each business unit has its own AWS account but shares a central data bucket, EventBridge is the only native way to deliver upload events cross-account without custom relay code.

The key-encoding trap that silently breaks MCP tool lookups

S3 event notifications encode object keys using URL percent-encoding with a twist: spaces are encoded as +, not %20. A file uploaded as uploads/Q4 2026 Report.pdf arrives in the SQS message body as uploads/Q4+2026+Report.pdf. If your MCP worker passes that raw string directly to a GetObjectCommand, S3 returns a 404 — the plus signs are not valid filename characters in the stored key.

The fix is one line, but it must be applied consistently at every point where your code reads a key from an S3 event notification body:

// In your SQS consumer, before any S3 API call:
const rawKey = record.body.s3.object.key;  // from SQS message
const key = decodeURIComponent(rawKey.replace(/\+/g, ' '));
// key is now the actual S3 object key, safe to pass to GetObjectCommand

This trap appears in CloudWatch logs as NoSuchKey errors on objects you can see in the S3 console — because the console displays the decoded key. AliveMCP's event pipeline canary catches this class of failure: the canary object key always contains a space character, so if the notification handler has the encoding bug, the first canary cycle reveals it before a real user upload is dropped.

Idempotency: at-least-once delivery with duplicate-safe tool handlers

S3 event notifications guarantee at-least-once delivery. Duplicate events are rare but real — they occur during S3 infrastructure maintenance events, when a notification delivery attempt times out and S3 retries, or when a worker crashes after processing an event but before deleting the SQS message. Your MCP tool handler must be idempotent: executing it twice on the same input must produce the same result as executing it once.

The idempotency key is the combination of the S3 bucket name, object key, and ETag from the event body. The ETag is an MD5 hash of the object content — it changes if the object is overwritten, so checking the ETag prevents re-processing a new version of a file that replaced an old one at the same key.

// In your MCP tool handler:
async function processS3Upload(event: S3EventRecord): Promise<void> {
  const { bucket, object } = event.s3;
  const key = decodeURIComponent(object.key.replace(/\+/g, ' '));
  const etag = object.eTag;  // MD5 of object content
  const idempotencyKey = `${bucket.name}/${key}/${etag}`;

  // Check if already processed (using SQLite or DynamoDB)
  const existing = db.prepare(
    'SELECT id FROM processed_uploads WHERE idempotency_key = ?'
  ).get(idempotencyKey);

  if (existing) {
    console.log(`Skipping duplicate event for ${idempotencyKey}`);
    return;  // delete the SQS message — we already processed this object
  }

  // Execute the MCP tool
  const result = await executeMcpTool({ bucket: bucket.name, key, etag });

  // Record as processed — do this AFTER the tool succeeds
  db.prepare(
    'INSERT INTO processed_uploads (idempotency_key, processed_at) VALUES (?, ?)'
  ).run(idempotencyKey, new Date().toISOString());
}

Record the idempotency key after the tool succeeds, not before. If you record it first and the tool fails, the event is marked as processed but the work was not done — you've silently dropped it without a retry. The correct order is: check → execute → record → delete SQS message.

For detailed configuration of all four notification destination types, filter patterns, and fan-out architectures, see S3 Event Notifications for MCP Servers.

Pattern 2 — Data Transformation and Multi-Tenant Isolation: Object Lambda and Access Points

The problem: every tool reads raw data

A multi-tenant MCP document server has a default data flow problem: raw customer documents stored in S3 often contain PII — SSNs, emails, phone numbers — that should not reach an LLM tool call. The obvious fix is to add redaction logic to each MCP tool individually. But as the number of tools grows (summarise, extract-entities, classify, index), each tool independently re-implements redaction logic, each implementation diverges slightly, and a regex update must be deployed across N tools simultaneously. One tool that forgets the update passes raw PII to the LLM.

S3 Object Lambda solves this at the infrastructure layer: add redaction once, at the S3 read path, and every tool that reads through the Object Lambda access point gets the cleaned version automatically — whether it's a new tool added next month or an old tool that has never been updated.

The Object Lambda architecture

Object Lambda uses three resources: a standard S3 access point (the backing access point), an Object Lambda access point, and a Lambda function. The flow is:

  1. MCP tool calls GetObject using the Object Lambda access point ARN as the bucket parameter
  2. S3 intercepts the request and invokes the Lambda with: the original request parameters, a presigned URL to fetch the raw object from the backing access point, and caller identity context (IAM principal, session tags)
  3. Lambda fetches the raw object via presigned URL, transforms it, and calls WriteGetObjectResponse with the transformed bytes
  4. S3 delivers the transformed bytes to the MCP tool caller
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3objectlambda from 'aws-cdk-lib/aws-s3objectlambda';
import * as lambda from 'aws-cdk-lib/aws-lambda';

const dataBucket = new s3.Bucket(this, 'DataBucket', {
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  encryption: s3.BucketEncryption.S3_MANAGED,
  versioned: true,
});

// Standard access point — the backing access point for Object Lambda
const backingAP = new s3.CfnAccessPoint(this, 'BackingAP', {
  bucket: dataBucket.bucketName,
  name: 'mcp-data-backing',
});

// Transformation Lambda
const transformFn = new lambda.Function(this, 'PiiRedactFn', {
  runtime: lambda.Runtime.NODEJS_22_X,
  handler: 'index.handler',
  code: lambda.Code.fromAsset('lambda/pii-redact'),
  timeout: cdk.Duration.seconds(60),
  memorySize: 1024,
});
transformFn.addToRolePolicy(new iam.PolicyStatement({
  actions: ['s3-object-lambda:WriteGetObjectResponse'],
  resources: ['*'],
}));

// Object Lambda access point — the endpoint MCP tools use
const objectLambdaAP = new s3objectlambda.CfnAccessPoint(this, 'PiiRedactAP', {
  name: 'mcp-data-pii-clean',
  objectLambdaConfiguration: {
    supportingAccessPoint: backingAP.attrArn,
    transformationConfigurations: [{
      actions: ['GetObject'],
      contentTransformation: {
        awsLambda: { functionArn: transformFn.functionArn },
      },
    }],
  },
});

PII redaction: the failure-mode contract

The most important implementation detail for a PII redaction transformer is the failure-mode contract: if the redaction Lambda fails for any reason, it must return a 500 error to the caller — not the raw object. Returning the raw object on error would silently bypass redaction, which is worse than a failed tool call from a compliance perspective.

// lambda/pii-redact/index.ts
const PII_PATTERNS: Array<[RegExp, string]> = [
  [/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN REDACTED]'],
  [/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, '[EMAIL REDACTED]'],
  [/\b\d{3}[-.\s]?\d{3}[-.\s]?\d{4}\b/g, '[PHONE REDACTED]'],
  [/\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13})\b/g, '[CARD REDACTED]'],
];

export async function handler(event: any): Promise<void> {
  const { outputRoute, outputToken, inputS3Url } = event.getObjectContext;

  try {
    const rawBytes = await fetchUrl(inputS3Url);
    let text = rawBytes.toString('utf-8');
    for (const [pattern, replacement] of PII_PATTERNS) {
      text = text.replace(pattern, replacement);
    }
    const transformed = Buffer.from(text, 'utf-8');

    await s3.send(new WriteGetObjectResponseCommand({
      RequestRoute: outputRoute,
      RequestToken: outputToken,
      Body: transformed,
      ContentLength: transformed.length,
      ContentType: 'text/plain',
      StatusCode: 200,
    }));
  } catch (err) {
    // CRITICAL: return 500, not the raw object
    await s3.send(new WriteGetObjectResponseCommand({
      RequestRoute: outputRoute,
      RequestToken: outputToken,
      StatusCode: 500,
      ErrorCode: 'TransformationFailed',
      ErrorMessage: `PII redaction failed: ${(err as Error).message}`,
    }));
  }
}

The 6 MB limit and the write-back pattern for large documents

Object Lambda's WriteGetObjectResponse accepts a maximum of 6 MB inline in the response body. A multi-page PDF or a large CSV file may exceed this limit. The write-back pattern handles this: instead of returning the transformed bytes inline, Lambda writes the transformed result to a temporary S3 prefix and returns a 307 redirect to a presigned URL for that result. The caller follows the redirect and fetches the large transformed object directly from S3.

// Write-back pattern for large objects
const transformedKey = `transformed/${outputToken}.txt`;
await s3source.send(new PutObjectCommand({
  Bucket: process.env.TRANSFORM_BUCKET,
  Key: transformedKey,
  Body: largeTransformedBytes,
  ContentType: 'text/plain',
}));

const presignedUrl = await getSignedUrl(s3source, new GetObjectCommand({
  Bucket: process.env.TRANSFORM_BUCKET,
  Key: transformedKey,
}), { expiresIn: 300 });

// Return redirect — S3 Object Lambda supports 307 for large objects
await s3.send(new WriteGetObjectResponseCommand({
  RequestRoute: outputRoute,
  RequestToken: outputToken,
  StatusCode: 307,
  Redirect: presignedUrl,
}));

S3 Access Points: per-tenant namespace isolation without bucket policy sprawl

The classic multi-tenant S3 problem: you store all tenant data in one shared bucket (shared lifecycle, encryption, replication — simpler operations) but each tenant's IAM role must only access its own prefix. The naive solution — one bucket policy enumerating every tenant's principal and prefix — works for 5 tenants, breaks for 50, and becomes unreviewed and dangerous at 500.

S3 Access Points solve this by delegating per-tenant access control to separate, independently-managed access point policies. Create one access point per tenant, scoped to data/{tenant-id}/. Each tenant IAM role gets access to only its access point. The shared bucket policy delegates to any access point in the same account with a single line — no per-tenant changes to the bucket policy when onboarding or offboarding.

OperationSingle bucket policyOne bucket per tenantS3 Access Points
Onboard new tenant Edit bucket policy (full redeploy) Create bucket + policy + lifecycle (3 resources) Create access point + policy (2 resources, no bucket policy change)
Offboard tenant Edit bucket policy (full redeploy) Delete bucket (drain data first) Delete access point (data stays for retention archiving)
Policy blast radius One malformed policy breaks all tenants Isolated per bucket Isolated per access point — one broken AP affects only one tenant
Operational overhead One policy grows without bound 10,000 bucket limit per account 10,000 access points per bucket — 10,000 tenants per shared bucket
// Shared bucket with delegation policy
const dataBucket = new s3.Bucket(this, 'TenantDataBucket', {
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  encryption: s3.BucketEncryption.S3_MANAGED,
  versioned: true,
});

// Bucket policy delegates to all access points in this account
dataBucket.addToResourcePolicy(new iam.PolicyStatement({
  sid: 'DelegateToAccessPoints',
  principals: [new iam.AnyPrincipal()],
  actions: ['s3:*'],
  resources: [dataBucket.bucketArn, `${dataBucket.bucketArn}/*`],
  conditions: {
    StringEquals: { 's3:DataAccessPointAccount': this.account },
  },
}));

// Per-tenant access point — repeat for each tenant
function createTenantAccessPoint(tenantId: string, tenantRoleArn: string) {
  return new s3.CfnAccessPoint(this, `AP-${tenantId}`, {
    bucket: dataBucket.bucketName,
    name: `mcp-tenant-${tenantId}`,
    policy: {
      Version: '2012-10-17',
      Statement: [{
        Effect: 'Allow',
        Principal: { AWS: tenantRoleArn },
        Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject'],
        Resource: `arn:aws:s3:*:*:accesspoint/mcp-tenant-${tenantId}/object/data/${tenantId}/*`,
      }],
    },
  });
}

VPC-only access points for private MCP deployments

For MCP servers deployed inside a VPC where S3 access should never traverse the public internet, access points support a VPC restriction that blocks all calls not originating from the specified VPC — even calls authenticated with valid IAM credentials from outside the VPC are rejected.

new s3.CfnAccessPoint(this, 'PrivateMcpAP', {
  bucket: dataBucket.bucketName,
  name: 'mcp-private-internal',
  vpcConfiguration: {
    vpcId: vpc.vpcId,  // Only calls from this VPC are accepted
  },
  policy: {
    Version: '2012-10-17',
    Statement: [{
      Effect: 'Allow',
      Principal: { AWS: mcpServerRole.roleArn },
      Action: ['s3:GetObject', 's3:PutObject'],
      Resource: 'arn:aws:s3:*:*:accesspoint/mcp-private-internal/object/*',
      Condition: {
        StringEquals: { 'aws:SourceVpc': vpc.vpcId },
      },
    }],
  },
});

Combining Object Lambda and Access Points for a per-tenant transformation pipeline

Object Lambda and Access Points compose: create one Object Lambda access point per tenant, backed by that tenant's standard access point (which is already scoped to their prefix). Each tenant's MCP tool calls GetObject through its Object Lambda AP; the Lambda function reads the caller's aws:PrincipalTag/TenantId from the event context and can apply tenant-specific transformation rules — different PII patterns for different regulatory environments, different field masks for different data classification levels.

The architecture: Tenant IAM role → Object Lambda AP (per-tenant) → Backing AP (per-tenant, scoped to data/{tenant-id}/) → Lambda transformer (reads TenantId tag) → shared bucket. Each tenant sees only its own data, transformed according to its own rules, through its own independently-managed policy chain.

For full implementation details and the format conversion (CSV → normalised JSON) and write-back patterns, see S3 Object Lambda for MCP Servers and S3 Access Points for Multi-Tenant MCP Servers.

Pattern 3 — Replication, Failover Integration, and Replication-Lag Monitoring

Why S3 replication is a prerequisite for GA failover, not an optional add-on

Teams deploying MCP servers behind AWS Global Accelerator active-passive or active-active multi-region configurations often configure the GA traffic dial and the multi-region ECS deployment first, leaving "S3 replication" as a follow-up item. This is backwards. An MCP server that fails over to a DR region needs the data in that region's S3 bucket the moment the first tool call arrives. Without replication, the bucket is empty; every tool that reads from S3 returns 404 or empty results; users experience a degraded failover that is operationally worse than the original outage.

S3 replication is a prerequisite for GA-based DR, not a follow-up. The right sequence: configure CRR → verify replication is healthy → then enable GA multi-region routing. Replication takes minutes to catch up on existing objects (via Batch Replication) and then operates continuously for new objects.

CRR vs SRR: choosing for each category of MCP data

Not all S3 data needs the same replication treatment. Map your MCP server's S3 objects by their DR impact:

S3 Prefix / Data CategoryDR Impact if Missing at FailoverRecommended ReplicationUse RTC?
user-uploads/ — customer-uploaded documents for tool processing Critical — tool calls return 404; users see failures CRR to DR region Yes — 15s SLA
config/ — MCP server configuration artifacts, tool schemas Critical — server starts but tools may behave incorrectly without latest config CRR to DR region Yes — 15s SLA
model-outputs/ — LLM responses cached for de-duplication Moderate — cache misses cause re-inference, higher cost, higher latency CRR to DR region No — 15min p99.9 acceptable
logs/ — access logs, tool execution traces None — logs are write-once audit trails, not read on the data path SRR to compliance account No
analytics/ — aggregated usage reports, billing snapshots None — analytics reads degrade gracefully to cached/stale data None or SRR No

Configuring CRR with RTC for critical prefixes

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

const sourceBucket = new s3.Bucket(this, 'McpSourceBucket', {
  bucketName: 'mcp-data-us-east-1',
  versioned: true,  // Required for replication
  blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
  encryption: s3.BucketEncryption.S3_MANAGED,
});

const replicationRole = new iam.Role(this, 'ReplicationRole', {
  assumedBy: new iam.ServicePrincipal('s3.amazonaws.com'),
  inlinePolicies: {
    ReplicationPolicy: new iam.PolicyDocument({
      statements: [
        new iam.PolicyStatement({
          actions: ['s3:GetReplicationConfiguration', 's3:ListBucket'],
          resources: [sourceBucket.bucketArn],
        }),
        new iam.PolicyStatement({
          actions: ['s3:GetObjectVersionForReplication', 's3:GetObjectVersionAcl', 's3:GetObjectVersionTagging'],
          resources: [`${sourceBucket.bucketArn}/*`],
        }),
        new iam.PolicyStatement({
          actions: ['s3:ReplicateObject', 's3:ReplicateDelete', 's3:ReplicateTags'],
          resources: [`${destinationBucketArn}/*`],
        }),
      ],
    }),
  },
});

// Apply replication configuration with RTC on critical prefixes only
const cfnSourceBucket = sourceBucket.node.defaultChild as s3.CfnBucket;
cfnSourceBucket.replicationConfiguration = {
  role: replicationRole.roleArn,
  rules: [
    {
      id: 'CriticalDataRTC',
      status: 'Enabled',
      filter: {
        // Only replicate user-uploads/ and config/ with RTC
        prefix: 'user-uploads/',
      },
      destination: {
        bucket: destinationBucketArn,
        replicationTime: {
          status: 'Enabled',
          time: { minutes: 15 },  // RTC: replicate within 15 minutes at p99.9
        },
        metrics: {
          status: 'Enabled',
          eventThreshold: { minutes: 15 },
        },
      },
      deleteMarkerReplication: { status: 'Enabled' },
    },
    {
      id: 'ConfigRTC',
      status: 'Enabled',
      filter: { prefix: 'config/' },
      destination: {
        bucket: destinationBucketArn,
        replicationTime: { status: 'Enabled', time: { minutes: 15 } },
        metrics: { status: 'Enabled', eventThreshold: { minutes: 15 } },
      },
      deleteMarkerReplication: { status: 'Enabled' },
    },
    {
      id: 'ModelOutputsNoRTC',
      status: 'Enabled',
      filter: { prefix: 'model-outputs/' },
      destination: {
        bucket: destinationBucketArn,
        // No RTC for model outputs — 15-minute p99.9 is acceptable
      },
    },
  ],
};

Bidirectional replication for active-active S3: loop prevention

Active-active MCP deployments — both regions serving live traffic simultaneously — require bidirectional S3 replication. Region A's MCP servers write user uploads to A's bucket; Region B's MCP servers write to B's bucket; both buckets need to see both sets of objects eventually.

Bidirectional replication requires two separate CRR rules: one from A→B and one from B→A. Without careful configuration, you create a replication loop: an object written to A is replicated to B, which triggers B→A replication, replicating it back to A, which triggers A→B again, infinitely. S3 prevents this loop automatically for exact replica objects — an object replicated from A to B is flagged as a replica and excluded from B→A rules. But two conditions can break loop prevention:

  1. ReplicaModifications not enabled: if a replica object at B is modified after replication (metadata update, content overwrite), S3 treats it as a new object, not a replica — enable ReplicaModifications: ENABLED on both rules to replicate these modifications back to A without triggering a loop
  2. SSE-KMS with mismatched keys: if A uses KMS key A and B uses KMS key B, an object replicated from A to B is re-encrypted with B's key — S3 may not recognise it as the same object for loop prevention purposes; use the same CMK in both regions or use SSE-S3 for cross-region replicated objects
// Rule at bucket-A: replicate to B, include modifications to replicas
{
  id: 'AToBBidirectional',
  status: 'Enabled',
  sourceSelectionCriteria: {
    replicaModifications: { status: 'Enabled' },  // replicate modifications to replicas
  },
  destination: { bucket: bucketBArn, ... },
}

// Rule at bucket-B: replicate to A (symmetric)
{
  id: 'BToABidirectional',
  status: 'Enabled',
  sourceSelectionCriteria: {
    replicaModifications: { status: 'Enabled' },
  },
  destination: { bucket: bucketAArn, ... },
}

Batch Replication: seeding the DR bucket for existing objects

Standard CRR only applies to objects written after the replication rule is created. If your source bucket already has 50 TB of user uploads when you add CRR, those existing objects are not replicated automatically. Run S3 Batch Replication as a one-time job on day 1 to copy all existing objects to the DR bucket, then let ongoing CRR handle new objects.

# Create an S3 Batch Operations job to replicate existing objects
aws s3control create-job \
  --account-id 123456789 \
  --operation '{"S3ReplicateObject": {}}' \
  --report '{"Bucket": "arn:aws:s3:::mcp-batch-reports", "Format": "Report_CSV_20180820", "Enabled": true, "ReportScope": "AllTasks"}' \
  --manifest-generator '{
    "S3JobManifestGenerator": {
      "SourceBucket": "arn:aws:s3:::mcp-data-us-east-1",
      "EnableManifestOutput": false,
      "Filter": {
        "ObjectReplicationStatuses": ["NONE", "FAILED"]
      }
    }
  }' \
  --role-arn arn:aws:iam::123456789:role/BatchReplicationRole \
  --priority 10 \
  --no-confirmation-required

The filter ObjectReplicationStatuses: ["NONE", "FAILED"] catches two categories: objects that existed before CRR was enabled (NONE) and objects where a previous replication attempt failed (FAILED). Run this job on a schedule (e.g., weekly) to catch any replication failures that accumulated since the last run.

AliveMCP replication-lag monitoring: catching stale DR before failover

Replication monitoring is fundamentally a lag problem: you can't know replication is failing until you measure whether replicated objects are arriving within the expected SLA window. The standard S3 CloudWatch metric ReplicationLatency shows the time between object creation and replication completion in minutes — but it only covers successfully replicated objects, not objects stuck in the replication queue due to permission errors, KMS key mismatches, or destination bucket policy issues.

AliveMCP's replication-lag probe works differently: it writes a synthetic canary object with a known timestamp to the source bucket every 5 minutes, then checks whether that specific object key exists in the DR replica bucket within the RTC SLA window (15 seconds + 30 seconds network margin = 45 seconds total). If the canary object is not present in the DR bucket after 45 seconds, it fires an alert with the specific source key and the observed lag — distinguishing between RTC SLA violation (object exists but arrived late), replication failure (object never arrived), and permission error (403 from the DR bucket on the check call).

// Replication lag probe (runs every 5 minutes in AliveMCP worker)
async function probeReplicationLag(sourceBucket: string, drBucket: string, drRegion: string) {
  const canaryKey = `canary/replication/${Date.now()}.json`;
  const timestamp = new Date().toISOString();

  // Write canary to source bucket
  await s3Source.send(new PutObjectCommand({
    Bucket: sourceBucket,
    Key: canaryKey,
    Body: JSON.stringify({ timestamp, probe: 'replication-lag' }),
  }));

  // Poll the DR bucket for up to 60 seconds
  const startMs = Date.now();
  const slaMs = 45_000;  // 15s RTC + 30s margin

  while (Date.now() - startMs < slaMs + 15_000) {
    await sleep(5_000);
    try {
      await s3Dr.send(new HeadObjectCommand({ Bucket: drBucket, Key: canaryKey }));
      const lagMs = Date.now() - startMs;
      recordMetric('replication_lag_ms', lagMs);
      if (lagMs > slaMs) {
        fireAlert(`Replication RTC SLA exceeded: ${lagMs}ms (SLA ${slaMs}ms) for key ${canaryKey}`);
      }
      return;  // Replication succeeded
    } catch (err) {
      if ((err as any).name !== 'NotFound') throw err;
    }
  }

  // Object not found after timeout
  fireAlert(`Replication FAILURE: canary object not replicated to DR after ${Date.now() - startMs}ms: ${canaryKey}`);
}

Pair the replication-lag probe with AliveMCP's main endpoint probe on the DR MCP server itself. The endpoint probe checks whether the DR MCP server responds to tool calls; the replication-lag probe checks whether the data those tool calls depend on is present. Both must be green before you trust a GA traffic dial switch to the DR region — a green endpoint probe with a red replication-lag probe means the DR server is up but would return empty results for any tool that reads user data from S3.

For CRR vs SRR comparison tables, Replication Time Control configuration, and selective replication by prefix and tag, see S3 Replication for MCP Servers.

Putting It Together: A Complete Multi-Tenant Document Processing Architecture

The four S3 advanced features compose naturally into a production multi-tenant MCP document analysis server. Here is how the four patterns connect:

Upload path: Access Points + Event Notifications

  1. A tenant uploads a document via a presigned URL generated against their tenant access point (ap-{tenant-id}). The access point policy enforces that the object lands in user-uploads/{tenant-id}/ — the tenant cannot write to any other prefix, and no other tenant can read their prefix, regardless of bucket policy state.
  2. S3 EventBridge fires an Object Created event. An EventBridge rule matches events from this bucket and routes them to a per-tenant SQS queue, identified by matching the access point ARN in the event detail. The MCP worker polling that queue processes only that tenant's uploads.
  3. The SQS consumer decodes the URL-encoded object key, checks the idempotency table by ETag, and if not already processed, invokes the document analysis MCP tool.

Read path: Object Lambda + Access Points

  1. The MCP tool reads the uploaded document using the Object Lambda access point (olap-{tenant-id}), which is backed by the tenant's standard access point.
  2. The PII redaction Lambda intercepts the read, fetches the raw document from the backing access point via presigned URL, applies redaction patterns (with tenant-specific patterns loaded from a config table keyed by TenantId session tag), and returns the cleaned bytes via WriteGetObjectResponse.
  3. The MCP tool sees only the redacted document. The raw version remains in S3 for compliance archiving, accessible only through direct API calls (not through the Object Lambda AP) with a separate IAM policy requiring MFA.

DR path: CRR + Global Accelerator

  1. CRR with RTC replicates every new object under user-uploads/ and config/ to the DR bucket in us-west-2 within 15 seconds.
  2. The DR region has its own Access Points and Object Lambda APs with identical configuration, deployed via CDK with the region parameter overridden.
  3. AliveMCP's replication-lag probe verifies every 5 minutes that the DR bucket's user-uploads/ prefix contains the latest canary object within 45 seconds. AliveMCP's endpoint probe verifies the DR MCP server responds to tool calls.
  4. If the primary region fails, the GA traffic dial switches to the DR region. The DR MCP server immediately serves tool calls against the DR S3 bucket — which already has all user uploads from the last 15 seconds of replication. Tenants see at most 15 seconds of missing upload data — only the objects that were in-flight during the replication window.

Operational monitoring: what AliveMCP watches end-to-end

ProbeWhat it detectsAlert threshold
Event delivery canary (S3 → SQS) S3 permission drift, SQS policy misconfiguration, resource limit exhaustion preventing notification delivery Canary object's SQS message not received within 120s
Object Lambda transform probe PII redaction Lambda timeout, runtime errors, WriteGetObjectResponse failures, Object Lambda AP misconfiguration Transform call fails or returns non-200 within 5s
Access point read probe Access point policy drift, bucket delegation policy removed, IAM role change blocking tenant access GetObject via access point returns non-200 within 3s
Replication lag probe (primary → DR) CRR rule disabled, KMS key permission revoked, DR bucket policy change blocking replication, RTC SLA exceeded Canary object not present in DR bucket within 45s
DR endpoint probe DR ECS service unhealthy, GA endpoint group pointing to unhealthy NLB, DR MCP server crash MCP server at DR endpoint returns non-200 within 5s

The last row is the key insight: the GA endpoint probe and the replication-lag probe are independent failure modes. An MCP server that is up (endpoint probe green) but has a stale S3 replica (replication probe red) would pass a conventional uptime check and fail in production the moment a tool tries to read user data. Both probes are required for a reliable DR posture — one verifies the network path, the other verifies the data plane.

Further Reading