Guide · AWS S3 · Data Transformation
S3 Object Lambda for MCP Servers
MCP tools that read from S3 often need the data transformed before they can use it: a raw CSV needs headers normalized, a JSON document needs PII fields redacted before being passed to an LLM, a binary blob needs to be converted to plain text. Without S3 Object Lambda, you add transformation logic to every tool individually. With Object Lambda, you intercept the GET request at the S3 layer and transform once — every downstream consumer gets the clean version automatically. This guide covers the S3 Object Lambda architecture (access points, Lambda functions, write-back), practical transformation patterns for MCP tool pipelines, and the sharp edges (size limits, latency, caching behavior) you'll hit in production. For S3 basics see MCP Tools for AWS S3; for triggering MCP tools on upload see S3 Event Notifications.
TL;DR
S3 Object Lambda adds a Lambda function between S3 GetObject and the caller. The stored object is unchanged; the Lambda receives the raw bytes and returns transformed bytes. Use it for: PII redaction before LLM tools read sensitive data, format conversion (CSV → JSON, binary → text), tenant-specific data filtering, and watermarking. Limitations: max 60s Lambda timeout, max 6MB response for inline delivery (use S3 write-back for larger objects), and no caching layer — every GET call invokes Lambda. For frequent reads of stable objects, consider materializing the transformed output as a separate S3 object instead.
Architecture overview
S3 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:
- MCP tool calls
GetObjecton the Object Lambda access point ARN (not the bucket directly) - S3 intercepts the request and invokes the Lambda function with: the original request parameters, a presigned URL to fetch the raw object from the backing access point, and caller identity context
- Lambda fetches the raw object via the presigned URL, transforms it, and calls
WriteGetObjectResponsewith the transformed bytes - S3 delivers the transformed bytes to the MCP tool caller
import * as cdk from 'aws-cdk-lib';
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';
// 1. Standard bucket
const dataBucket = new s3.Bucket(this, 'DataBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: true,
});
// 2. Backing access point (standard S3 access point on the bucket)
const backingAccessPoint = new s3.CfnAccessPoint(this, 'BackingAccessPoint', {
bucket: dataBucket.bucketName,
name: 'mcp-data-backing',
// No bucket policy needed — Object Lambda uses the presigned URL directly
});
// 3. Transformation Lambda
const transformFn = new lambda.Function(this, 'TransformFn', {
runtime: lambda.Runtime.NODEJS_22_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda/transform'),
timeout: cdk.Duration.seconds(60),
memorySize: 1024,
environment: {
BACKING_ACCESS_POINT_ARN: backingAccessPoint.attrArn,
},
});
// Lambda needs permission to call WriteGetObjectResponse
transformFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['s3-object-lambda:WriteGetObjectResponse'],
resources: ['*'], // WriteGetObjectResponse uses the request token, not an ARN resource
}));
// 4. Object Lambda access point
const objectLambdaAP = new s3objectlambda.CfnAccessPoint(this, 'ObjectLambdaAP', {
name: 'mcp-data-transform',
objectLambdaConfiguration: {
supportingAccessPoint: backingAccessPoint.attrArn,
transformationConfigurations: [{
actions: ['GetObject'],
contentTransformation: {
awsLambda: {
functionArn: transformFn.functionArn,
},
},
}],
},
});
MCP tools must use the Object Lambda access point ARN as the bucket-equivalent in their S3 client calls. The access point ARN looks like arn:aws:s3-object-lambda:us-east-1:123456789:accesspoint/mcp-data-transform. Pass this as the Bucket parameter in your GetObjectCommand.
PII redaction for LLM tool calls
The most valuable Object Lambda pattern for MCP servers: raw customer documents stored in S3 may contain PII (emails, phone numbers, SSNs) that should not be passed to an LLM tool. Object Lambda intercepts the read and redacts PII fields before the bytes reach your MCP tool.
// lambda/transform/index.ts
import {
S3Client,
WriteGetObjectResponseCommand,
} from '@aws-sdk/client-s3';
import { Readable } from 'stream';
import * as https from 'https';
const s3 = new S3Client({ region: process.env.AWS_REGION });
// Simple regex-based PII patterns — in production, use AWS Comprehend or Macie
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 {
const { getObjectContext, userRequest } = event;
const { outputRoute, outputToken, inputS3Url } = getObjectContext;
try {
// Fetch the raw object using the presigned URL provided by S3
const rawBytes = await fetchUrl(inputS3Url);
const contentType = event.userRequest?.headers?.['Accept'] || 'text/plain';
let transformed: Buffer;
if (contentType.includes('application/json') || inputS3Url.includes('.json')) {
// JSON: parse and redact string values
const json = JSON.parse(rawBytes.toString('utf-8'));
const redacted = redactObject(json);
transformed = Buffer.from(JSON.stringify(redacted), 'utf-8');
} else {
// Plain text: apply regex redaction
let text = rawBytes.toString('utf-8');
for (const [pattern, replacement] of PII_PATTERNS) {
text = text.replace(pattern, replacement);
}
transformed = Buffer.from(text, 'utf-8');
}
// Write the transformed bytes back to the caller via Object Lambda
await s3.send(new WriteGetObjectResponseCommand({
RequestRoute: outputRoute,
RequestToken: outputToken,
Body: transformed,
ContentLength: transformed.length,
ContentType: 'text/plain',
StatusCode: 200,
}));
} catch (err) {
// On transformation failure, return a 500 to the caller
// Do NOT return the raw object — that would bypass redaction
await s3.send(new WriteGetObjectResponseCommand({
RequestRoute: outputRoute,
RequestToken: outputToken,
StatusCode: 500,
ErrorCode: 'TransformationFailed',
ErrorMessage: `PII redaction failed: ${(err as Error).message}`,
}));
}
}
function redactObject(obj: unknown): unknown {
if (typeof obj === 'string') {
let s = obj;
for (const [pattern, replacement] of PII_PATTERNS) {
s = s.replace(pattern, replacement);
}
return s;
}
if (Array.isArray(obj)) return obj.map(redactObject);
if (obj && typeof obj === 'object') {
return Object.fromEntries(
Object.entries(obj as Record).map(([k, v]) => [k, redactObject(v)])
);
}
return obj;
}
function fetchUrl(url: string): Promise {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
https.get(url, (res) => {
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks)));
res.on('error', reject);
}).on('error', reject);
});
}
Critical safety rule: if your transformation Lambda fails, always call WriteGetObjectResponse with a StatusCode: 500 error response — never return the raw bytes as a fallback. Falling back to raw data defeats the entire purpose of the redaction layer and could expose PII to LLM tools.
Format conversion: CSV → JSON for structured tool inputs
MCP tools that parse structured data prefer JSON over CSV — it's self-describing, handles nested structures, and avoids delimiter ambiguity. Store raw CSV in S3 (preserve the original), serve transformed JSON via Object Lambda:
import { parse as parseCsv } from 'csv-parse/sync';
async function transformCsvToJson(rawBytes: Buffer): Promise {
const csv = rawBytes.toString('utf-8');
const records = parseCsv(csv, {
columns: true, // first row is header
skip_empty_lines: true,
trim: true,
cast: true, // auto-cast numbers and booleans
});
// Normalize: lowercase keys, remove empty-string values
const normalized = records.map((row: Record) =>
Object.fromEntries(
Object.entries(row)
.filter(([, v]) => v !== '' && v !== null)
.map(([k, v]) => [k.toLowerCase().replace(/\s+/g, '_'), v])
)
);
return Buffer.from(JSON.stringify({ records: normalized, count: normalized.length }, null, 2), 'utf-8');
}
The Object Lambda function can be context-aware via the userRequest.url field — the URL includes query parameters and the original object key path. If your MCP tool passes ?format=json in the presigned URL, the Lambda can conditionally transform; without that parameter, it returns the raw CSV. This allows the same Object Lambda access point to serve both raw and transformed versions based on caller intent.
Tenant-specific data filtering for multi-tenant MCP
Multi-tenant MCP servers often store all customer data in a shared S3 bucket (simpler lifecycle management) but need to ensure each customer only reads their own records. Object Lambda can enforce this at the read layer:
// The caller must include their tenant ID as a user-agent or custom header
// S3 Object Lambda passes userRequest.headers to your Lambda function
async function filterByTenant(
rawBytes: Buffer,
tenantId: string,
): Promise {
const data = JSON.parse(rawBytes.toString('utf-8')) as Array<{tenant_id: string; [key: string]: unknown}>;
// Filter to only this tenant's records
const filtered = data.filter(record => record.tenant_id === tenantId);
return Buffer.from(JSON.stringify(filtered), 'utf-8');
}
// In your handler, extract tenant context from the caller identity:
const callerTenantId = event.userIdentity?.sessionContext?.sessionIssuer?.userName
?? event.userRequest.headers['x-tenant-id'];
if (!callerTenantId) {
throw new Error('Tenant context missing — access denied');
}
This pattern has a subtle vulnerability: if the x-tenant-id header is user-supplied rather than coming from IAM session context, it can be spoofed. The safe implementation uses IAM-derived tenant identity (the IAM role name or session tag) rather than a header the caller controls. Use IAM session tags via sts:TagSession when the caller assumes a role — the tag is embedded in the signed request and cannot be forged.
Size limits, latency, and caching trade-offs
Object Lambda has hard limits that matter for MCP tool design:
| Constraint | Limit | Workaround |
|---|---|---|
| Lambda function timeout | 60 seconds | For large objects, stream in chunks or pre-materialize transformed output |
| Inline response size (WriteGetObjectResponse Body) | 6 MB | For >6 MB transformed output, write to a temp S3 location and redirect with 307 |
| Raw object fetch size via presigned URL | No hard limit, but Lambda /tmp is 10 GB | Stream processing for large objects instead of full-buffer approach |
| Concurrent Lambda invocations | Shares regional Lambda concurrency limit | Set reserved concurrency on transform Lambda; don't let S3 burst exhaust account concurrency |
| Caching | None built-in — every GET invokes Lambda | For stable objects, materialize transformed output on first read; skip Lambda on cache hit |
The no-caching problem: if your MCP server reads the same S3 object frequently (e.g., a configuration file read on every tool call), Object Lambda invokes the transformation Lambda every time — even if the underlying object hasn't changed. For frequently-read, rarely-changed objects, use a lazy materialization pattern: write the transformed output to a separate key on first transformation, and check for that key before routing to the Object Lambda access point. Only use Object Lambda for objects that change frequently or where transformation must be fresh on every read.
Latency overhead: Object Lambda adds a Lambda cold start (0–3s) or warm invocation latency (~50–200ms) to every GetObject call. For MCP tools where response latency is visible to the user, the transformation must be fast. PII regex redaction on a 100KB text file takes ~10ms; a full Comprehend PII detection call takes ~500ms per call. If latency is critical, prefer offline pre-materialization over real-time Object Lambda transformation.
How AliveMCP monitors Object Lambda pipelines
Object Lambda failures are silent from the caller's perspective — the MCP tool receives a 500 error, but without monitoring, you won't know whether it's a Lambda failure, a PII redaction edge case, or an S3 access point misconfiguration. AliveMCP adds visibility through:
- Lambda error rate monitoring: CloudWatch
Errorsmetric on your transform Lambda. Any non-zero error rate in a 5-minute window triggers an alert — especially important for security-critical transforms like PII redaction where errors mean MCP tools are failing to read data. - Duration p99 monitoring: if your transform Lambda's p99 duration approaches the 60s timeout, it's a leading indicator that some objects are too large or the transformation is slow for certain inputs. AliveMCP alerts when p99 exceeds 80% of your configured timeout.
- Transformation bypass detection: on Team+ tier, AliveMCP can deploy a synthetic canary that reads a known test object via the Object Lambda access point and validates that PII in the test object was correctly redacted in the response. If the known PII patterns appear in the canary response, AliveMCP raises a "redaction bypass" critical alert.
The transformation bypass canary is particularly valuable for compliance use cases — it gives you continuous evidence that your redaction layer is working, which feeds into compliance audit reports automatically.