Guide · MCP Cloud Integration
MCP Server AWS S3 — GetObject, PutObject, presigned URLs, and health monitoring
AWS S3 is one of the most common storage backends for MCP servers — agent tools need to read documents, write outputs, list available files, and generate temporary download links. This guide covers exposing S3 operations as MCP tools using the AWS SDK v3 (@aws-sdk/client-s3), key security patterns for scoping bucket access, generating presigned URLs, handling large files with streaming and multipart uploads, and instrumenting a /health/s3 endpoint for AliveMCP to monitor.
TL;DR
Use @aws-sdk/client-s3 with a singleton S3Client. Never accept arbitrary bucket names from callers — hard-code the bucket in the server or validate against an explicit allow-list. Stream large objects with GetObjectCommand and convert the Body stream to a string before returning it in tool content. Generate presigned URLs with @aws-sdk/s3-request-presigner instead of returning raw bytes for files callers should download directly. Wire a /health/s3 endpoint that runs HeadBucketCommand to verify bucket access is working.
S3Client setup and IAM authentication
The AWS SDK v3 uses modular packages — install only what you need. For S3 operations, you need @aws-sdk/client-s3 and (for presigned URLs) @aws-sdk/s3-request-presigner.
import { S3Client } from '@aws-sdk/client-s3';
// Singleton client — reused across all tool calls
// Credentials resolved from environment automatically:
// - AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (explicit)
// - AWS_PROFILE (named profile in ~/.aws/credentials)
// - EC2/ECS/Lambda instance role (automatic when deployed to AWS)
const s3 = new S3Client({
region: process.env.AWS_REGION ?? 'us-east-1',
// Optional: endpoint override for LocalStack or S3-compatible APIs
// endpoint: 'http://localhost:4566',
// forcePathStyle: true, // required for LocalStack
// Optional: custom retry strategy
maxAttempts: 3
});
// The bucket name is locked at server config time — never from caller input
const BUCKET = process.env.S3_BUCKET_NAME;
if (!BUCKET) throw new Error('S3_BUCKET_NAME environment variable required');
export { s3, BUCKET };
The critical security rule: the bucket name must never come from the MCP tool caller. If an agent can specify an arbitrary bucket name, it can potentially access other buckets the IAM role has access to. Hard-code BUCKET from an environment variable. Similarly, validate key prefixes — if a tool lets callers specify an S3 key, reject keys that contain ../, start with /, or include shell metacharacters.
IAM policy for the MCP server's role — grant minimum required permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "McpServerS3Access",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket",
"s3:HeadObject",
"s3:HeadBucket"
],
"Resource": [
"arn:aws:s3:::your-bucket-name",
"arn:aws:s3:::your-bucket-name/*"
]
}
]
}
Do not grant s3:* — restrict to the exact operations the MCP server needs. If the server only reads, omit s3:PutObject and s3:DeleteObject entirely.
Core S3 tool patterns
import {
GetObjectCommand,
PutObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand
} from '@aws-sdk/client-s3';
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { s3, BUCKET } from './s3-client.js';
// Validate S3 keys from caller input
function validateKey(key: string): void {
if (!key || key.includes('..') || key.startsWith('/') || /[\0\r\n]/.test(key)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid S3 key: ${key}`);
}
}
// Helper: stream S3 Body to string
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
}
return Buffer.concat(chunks).toString('utf-8');
}
// ---- get_object ----
server.tool(
'get_object',
{
key: z.string().min(1).max(1024),
encoding: z.enum(['utf-8', 'base64']).default('utf-8')
},
async ({ key, encoding }) => {
validateKey(key);
try {
const response = await s3.send(new GetObjectCommand({
Bucket: BUCKET,
Key: key
}));
if (!response.Body) {
throw new McpError(ErrorCode.InternalError, 'Empty response from S3');
}
const content = await streamToString(response.Body as NodeJS.ReadableStream);
const encoded = encoding === 'base64'
? Buffer.from(content, 'binary').toString('base64')
: content;
return {
content: [{
type: 'text',
text: JSON.stringify({
key,
content: encoded,
encoding,
content_type: response.ContentType,
content_length: response.ContentLength,
last_modified: response.LastModified?.toISOString(),
etag: response.ETag
})
}]
};
} catch (err: unknown) {
const awsErr = err as { name?: string };
if (awsErr.name === 'NoSuchKey') {
throw new McpError(ErrorCode.InvalidParams, `Object not found: ${key}`);
}
throw err;
}
}
);
// ---- put_object ----
server.tool(
'put_object',
{
key: z.string().min(1).max(1024),
content: z.string(),
content_type: z.string().default('text/plain; charset=utf-8'),
encoding: z.enum(['utf-8', 'base64']).default('utf-8')
},
async ({ key, content, content_type, encoding }) => {
validateKey(key);
const body = encoding === 'base64'
? Buffer.from(content, 'base64')
: Buffer.from(content, 'utf-8');
const response = await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: content_type,
ContentLength: body.length
}));
return {
content: [{
type: 'text',
text: JSON.stringify({ key, etag: response.ETag, bytes: body.length })
}]
};
}
);
// ---- list_objects ----
server.tool(
'list_objects',
{
prefix: z.string().default(''),
max_keys: z.number().int().min(1).max(1000).default(100),
continuation_token: z.string().optional()
},
async ({ prefix, max_keys, continuation_token }) => {
const response = await s3.send(new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: prefix,
MaxKeys: max_keys,
ContinuationToken: continuation_token
}));
return {
content: [{
type: 'text',
text: JSON.stringify({
objects: (response.Contents ?? []).map((obj) => ({
key: obj.Key,
size: obj.Size,
last_modified: obj.LastModified?.toISOString(),
etag: obj.ETag
})),
is_truncated: response.IsTruncated,
next_continuation_token: response.NextContinuationToken,
count: response.KeyCount
}, null, 2)
}]
};
}
);
// ---- delete_object ----
server.tool(
'delete_object',
{
key: z.string().min(1).max(1024),
confirm: z.literal(true) // require explicit confirmation
},
async ({ key }) => {
validateKey(key);
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
return {
content: [{ type: 'text', text: JSON.stringify({ deleted: key }) }]
};
}
);
The confirm: z.literal(true) pattern on delete_object requires the caller to explicitly pass confirm: true — it prevents accidental deletion from agents that call tools without reviewing parameters carefully. It's a lightweight guard that costs nothing but provides a meaningful speed bump.
Presigned URLs for large file downloads
When an agent needs to provide a download link to a user (rather than reading file content directly), generate a presigned URL. Presigned URLs are time-limited, signed by your AWS credentials, and allow the holder to download the object without needing AWS credentials themselves.
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// ---- generate_download_url ----
server.tool(
'generate_download_url',
{
key: z.string().min(1).max(1024),
expires_in_seconds: z.number().int().min(60).max(604800).default(3600) // 1 hour default, max 7 days
},
async ({ key, expires_in_seconds }) => {
validateKey(key);
// Verify the object exists before generating a URL for it
try {
await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
} catch (err: unknown) {
const awsErr = err as { name?: string };
if (awsErr.name === 'NotFound' || awsErr.name === 'NoSuchKey') {
throw new McpError(ErrorCode.InvalidParams, `Object not found: ${key}`);
}
throw err;
}
const command = new GetObjectCommand({ Bucket: BUCKET, Key: key });
const url = await getSignedUrl(s3, command, { expiresIn: expires_in_seconds });
const expiresAt = new Date(Date.now() + expires_in_seconds * 1000);
return {
content: [{
type: 'text',
text: JSON.stringify({
url,
key,
expires_at: expiresAt.toISOString(),
expires_in_seconds
})
}]
};
}
);
// ---- generate_upload_url ----
// Presigned URL for direct client upload — agent gets URL, user uploads directly to S3
server.tool(
'generate_upload_url',
{
key: z.string().min(1).max(1024),
content_type: z.string().default('application/octet-stream'),
expires_in_seconds: z.number().int().min(60).max(3600).default(900) // 15 min max for uploads
},
async ({ key, content_type, expires_in_seconds }) => {
validateKey(key);
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: content_type
});
const url = await getSignedUrl(s3, command, { expiresIn: expires_in_seconds });
const expiresAt = new Date(Date.now() + expires_in_seconds * 1000);
return {
content: [{
type: 'text',
text: JSON.stringify({
upload_url: url,
key,
content_type,
expires_at: expiresAt.toISOString(),
instructions: 'PUT the file body to upload_url with Content-Type header matching content_type'
})
}]
};
}
);
Presigned upload URLs allow agents to give end users a direct upload path to S3 without routing the file through the MCP server. This is important for large files — the MCP tool response size limit makes it impractical to accept large binary files as tool parameters.
Health endpoint: /health/s3
import express from 'express';
import { HeadBucketCommand } from '@aws-sdk/client-s3';
const app = express();
app.get('/health/s3', async (_req, res) => {
const start = Date.now();
try {
await s3.send(new HeadBucketCommand({ Bucket: BUCKET }));
const latencyMs = Date.now() - start;
res.json({
status: 'ok',
bucket: BUCKET,
latency_ms: latencyMs,
region: process.env.AWS_REGION
});
} catch (err: unknown) {
const awsErr = err as { name?: string; $metadata?: { httpStatusCode?: number } };
const statusCode = awsErr.$metadata?.httpStatusCode;
res.status(503).json({
status: 'error',
error: awsErr.name,
http_status: statusCode,
elapsed_ms: Date.now() - start,
// Common causes:
// 403 — IAM credentials invalid or missing s3:HeadBucket permission
// 404 — Bucket does not exist (or wrong region)
// ECONNREFUSED — VPC endpoint issue or SDK misconfigured
});
}
});
app.listen(3001);
HeadBucketCommand verifies both network connectivity to S3 and that the IAM credentials have at least read access to the bucket. A 403 on HeadBucket means the credentials have expired or the IAM policy changed — the bucket exists but the server can't access it. A 404 means the bucket name is wrong or the region is misconfigured.
| S3 failure mode | HeadBucket response | Detection |
|---|---|---|
| IAM credentials expired | 403 Forbidden | Yes — /health/s3 returns 503 |
| Bucket deleted | 404 Not Found | Yes — /health/s3 returns 503 |
| S3 regional outage | Timeout or 5xx | Yes — /health/s3 returns 503 after timeout |
| S3 throttling (specific object) | 200 (bucket is fine) | No — only affects specific high-traffic keys |
| Object deleted by another process | 200 (bucket is fine) | No — track per-tool error rate separately |
Frequently asked questions
How do I handle S3 objects larger than the MCP response limit?
For files too large to return as tool content, have the tool return a presigned URL instead of the file content. The caller (or the user the agent is serving) can then download the file directly from S3. For documents that agents need to read, use S3 Select to retrieve only the rows or fields needed, or implement chunked reading: add a byte_range parameter (start + end) that maps to the Range header on GetObjectCommand. For multi-agent pipelines where one agent writes a large file and another reads it, use S3 as a handoff layer: writer tool returns the key, reader tool accepts the key and downloads only what it needs.
Should I use S3 Transfer Acceleration for MCP servers?
Only if your MCP server and end users are geographically distributed and your tools do frequent large uploads. Transfer Acceleration routes uploads through CloudFront edge locations, which helps when the source and S3 bucket region are far apart. For a typical MCP server deployed in the same region as the S3 bucket, the latency difference is negligible. Acceleration costs extra (per GB transferred through CloudFront). Enable it per-bucket and add useAccelerateEndpoint: true to your S3Client config.
How do I test S3 tool calls locally without AWS credentials?
Use LocalStack. Run docker run -p 4566:4566 localstack/localstack, then configure your S3Client with endpoint: 'http://localhost:4566' and forcePathStyle: true. Set AWS_ACCESS_KEY_ID=test and AWS_SECRET_ACCESS_KEY=test — LocalStack accepts any non-empty credentials. Create buckets with the AWS CLI: aws --endpoint-url=http://localhost:4566 s3 mb s3://your-bucket-name. LocalStack's free tier supports all core S3 operations. For CI, use the localstack/localstack Docker image in your pipeline.
How do I scope tool access to a specific key prefix?
Validate the key against a required prefix in the tool handler: if (!key.startsWith(ALLOWED_PREFIX)) throw new McpError(ErrorCode.InvalidParams, ...). Also scope the IAM policy to that prefix: "Resource": "arn:aws:s3:::your-bucket/uploads/*". Prefix scoping in both the tool code and IAM policy provides defense in depth — a bug in the tool's validation doesn't grant broader access because IAM will still reject the S3 call.
Further reading
- MCP Server AWS Lambda — invoking Lambda functions from MCP tools
- AWS MCP Monitoring — monitoring MCP servers deployed on AWS
- MCP Server Health Check — designing endpoints for uptime monitors
- MCP Server Error Handling — mapping AWS SDK errors to McpError codes
- MCP Server Authentication — IAM roles and credential management