Guide · Multi-modal & Media Integration
MCP Server S3 Tools — File Upload, Download, and Presigned URLs
S3-compatible file storage is a natural backing service for MCP servers that need to store, retrieve, or share binary files: documents, images, exports, and processed artifacts. This guide covers integrating AWS S3 (and compatible services like Cloudflare R2, MinIO, and Backblaze B2) into a TypeScript MCP server — the S3 client singleton, upload and download tools, presigned URL generation, exposing buckets as MCP resources, IAM least-privilege credential setup, and a /health/s3 endpoint so AliveMCP detects credential expiry or S3 connectivity failures before users do.
TL;DR
Create one S3Client at server startup and reuse it across all tool calls — the SDK handles connection pooling internally. Never accept a raw S3 key prefix from MCP callers and pass it to PutObject without sanitization — callers can escape the intended prefix and overwrite arbitrary keys. Always use path.posix.normalize(key) and verify the result still starts with your expected prefix. Return presigned download URLs (valid for 15–60 minutes) rather than raw object data when files exceed 1 MB — returning large files inline as base64 bloats the MCP response and overflows client buffers. Wire /health/s3 to run a HeadBucket check rather than just testing process liveness — IAM credential expiry silently breaks all S3 tools.
S3 client setup
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadBucketCommand
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Singleton S3 client — created once at server startup
let _s3: S3Client | null = null;
export function getS3Client(): S3Client {
if (_s3) return _s3;
_s3 = new S3Client({
region: process.env.AWS_REGION ?? 'us-east-1',
// Credentials from environment (IAM role, EC2 instance profile, or explicit keys)
// Do not hard-code credentials — use the credential chain:
// 1. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY env vars
// 2. ~/.aws/credentials file
// 3. EC2 instance profile / ECS task role / Lambda execution role
// 4. Web identity token (EKS Pod Identity)
// For S3-compatible services (R2, MinIO, Backblaze B2):
// endpoint: process.env.S3_ENDPOINT,
// forcePathStyle: true, // required for MinIO and some B2 configs
});
return _s3;
}
export const BUCKET = process.env.S3_BUCKET ?? 'mcp-server-files';
export const KEY_PREFIX = process.env.S3_KEY_PREFIX ?? 'uploads/'; // trailing slash required
// Sanitize an S3 key to prevent path traversal
export function sanitizeKey(userKey: string): string {
// Remove null bytes, normalize path separators
const cleaned = userKey.replace(/\0/g, '').replace(/\\/g, '/');
// Resolve any ../ sequences
const normalized = cleaned.split('/').reduce((acc: string[], part) => {
if (part === '..' && acc.length > 0) acc.pop();
else if (part !== '.') acc.push(part);
return acc;
}, []).join('/');
const full = KEY_PREFIX + normalized;
if (!full.startsWith(KEY_PREFIX)) {
throw new McpError(ErrorCode.InvalidParams, 'Key would escape the allowed prefix');
}
return full;
}
The credential chain lookup order is important: prefer IAM roles over explicit key/secret pairs. If you're running on EC2, ECS, or Lambda, set the task/execution role with S3 permissions and never pass AWS_ACCESS_KEY_ID — the SDK resolves the instance profile automatically. For local development, use ~/.aws/credentials with a named profile and set AWS_PROFILE.
Upload tool
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { getS3Client, sanitizeKey, BUCKET } from './s3.js';
import path from 'node:path/posix';
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100 MB — use multipart for larger
const ALLOWED_MIME_TYPES = new Set([
'application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/gif',
'text/plain', 'text/csv', 'application/json', 'application/zip',
'video/mp4', 'audio/mpeg', 'audio/wav'
]);
server.tool(
'upload_file',
{
key: z.string().min(1).max(256).describe('Object key (relative to prefix, e.g. "documents/report.pdf")'),
content_base64: z.string().min(1),
content_type: z.string().min(1),
metadata: z.record(z.string()).optional().describe('Custom S3 object metadata (key-value pairs)')
},
async ({ key, content_base64, content_type, metadata }) => {
if (!ALLOWED_MIME_TYPES.has(content_type)) {
throw new McpError(ErrorCode.InvalidParams, `Content type ${content_type} is not allowed`);
}
const body = Buffer.from(content_base64, 'base64');
if (body.length > MAX_UPLOAD_BYTES) {
throw new McpError(ErrorCode.InvalidParams, `File too large: ${(body.length / 1e6).toFixed(1)} MB`);
}
const s3Key = sanitizeKey(key);
const s3 = getS3Client();
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: s3Key,
Body: body,
ContentType: content_type,
ContentLength: body.length,
// Server-side encryption — always encrypt at rest
ServerSideEncryption: 'AES256',
// Custom metadata (keys must be ASCII, values are strings)
Metadata: metadata ?? {}
}));
return {
content: [{
type: 'text',
text: JSON.stringify({
bucket: BUCKET,
key: s3Key,
size_bytes: body.length,
content_type,
s3_uri: `s3://${BUCKET}/${s3Key}`
})
}]
};
}
);
Download tool with presigned URLs
For files under ~1 MB, return the content inline as base64. For larger files, return a presigned download URL instead — this avoids loading megabytes into the MCP response and lets the caller download directly from S3 at full bandwidth.
server.tool(
'download_file',
{
key: z.string().min(1).max(256),
inline_if_under_kb: z.number().int().min(0).max(10000).default(512),
presigned_url_expires_seconds: z.number().int().min(60).max(86400).default(900)
},
async ({ key, inline_if_under_kb, presigned_url_expires_seconds }) => {
const s3Key = sanitizeKey(key);
const s3 = getS3Client();
// Get metadata first to check size without downloading
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: s3Key }))
.catch(err => {
if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) {
throw new McpError(ErrorCode.InvalidParams, `Object not found: ${key}`);
}
throw err;
});
const sizeBytes = head.ContentLength ?? 0;
const inlineThreshold = inline_if_under_kb * 1024;
if (sizeBytes <= inlineThreshold) {
// Small file — return inline
const response = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: s3Key }));
const chunks: Uint8Array[] = [];
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const contentType = (head.ContentType ?? 'application/octet-stream') as string;
const isImage = contentType.startsWith('image/');
return {
content: isImage
? [{ type: 'image', data: buffer.toString('base64'), mimeType: contentType }]
: [{ type: 'text', text: buffer.toString('base64') }]
};
} else {
// Large file — return presigned URL
const presignedUrl = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: BUCKET, Key: s3Key }),
{ expiresIn: presigned_url_expires_seconds }
);
return {
content: [{
type: 'text',
text: JSON.stringify({
presigned_url: presignedUrl,
expires_in_seconds: presigned_url_expires_seconds,
size_bytes: sizeBytes,
content_type: head.ContentType,
key: s3Key
})
}]
};
}
}
);
Exposing buckets as MCP resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const s3 = getS3Client();
const response = await s3.send(new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: KEY_PREFIX,
MaxKeys: 200
}));
return {
resources: (response.Contents ?? []).map(obj => ({
uri: `s3://${BUCKET}/${obj.Key}`,
name: obj.Key!.slice(KEY_PREFIX.length), // strip prefix for display
mimeType: undefined // determined at read time
}))
};
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
// Accept both s3:// URIs and relative keys
let s3Key: string;
if (uri.startsWith(`s3://${BUCKET}/`)) {
s3Key = uri.slice(`s3://${BUCKET}/`.length);
} else {
s3Key = sanitizeKey(uri);
}
if (!s3Key.startsWith(KEY_PREFIX)) {
throw new McpError(ErrorCode.InvalidParams, `Resource outside allowed prefix: ${uri}`);
}
const s3 = getS3Client();
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: s3Key }))
.catch(() => { throw new McpError(ErrorCode.InvalidParams, `Not found: ${uri}`); });
const response = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: s3Key }));
const chunks: Uint8Array[] = [];
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) chunks.push(chunk);
const buffer = Buffer.concat(chunks);
const mimeType = head.ContentType ?? 'application/octet-stream';
return {
contents: [{ uri, mimeType, blob: buffer.toString('base64') }]
};
});
| S3-compatible service | Extra SDK config | Free egress | Best for |
|---|---|---|---|
| AWS S3 | None (native) | No (~$0.09/GB) | AWS-native stacks |
| Cloudflare R2 | endpoint: 'https://<accountId>.r2.cloudflarestorage.com' |
Yes (zero egress) | High-egress workloads |
| MinIO | endpoint + forcePathStyle: true |
N/A (self-hosted) | On-premise / air-gapped |
| Backblaze B2 | endpoint: 'https://s3.us-west-004.backblazeb2.com' |
Partial (Cloudflare network) | Cost-sensitive storage |
Health endpoint for S3 monitoring
http.get('/health/s3', async (req, reply) => {
const start = Date.now();
const s3 = getS3Client();
try {
// HeadBucket verifies: credentials valid, bucket exists, network reachable
await s3.send(new HeadBucketCommand({ Bucket: BUCKET }));
return reply.send({
status: 'ok',
latency_ms: Date.now() - start,
bucket: BUCKET,
region: process.env.AWS_REGION
});
} catch (err: unknown) {
const typedErr = err as { name?: string; $metadata?: { httpStatusCode?: number }; message?: string };
const status = typedErr.$metadata?.httpStatusCode;
if (status === 403) {
// Credentials valid but no permission — misconfigured IAM
return reply.code(503).send({
status: 'error',
detail: 'iam_permission_denied',
latency_ms: Date.now() - start
});
}
if (status === 404) {
return reply.code(503).send({
status: 'error',
detail: 'bucket_not_found',
latency_ms: Date.now() - start
});
}
// Credential expiry typically surfaces as 403 or a specific auth error
if (typedErr.name === 'CredentialsProviderError' || typedErr.name === 'InvalidAccessKeyId') {
return reply.code(503).send({
status: 'error',
detail: 'credentials_expired',
latency_ms: Date.now() - start
});
}
return reply.code(503).send({
status: 'error',
detail: typedErr.message ?? 'unknown',
latency_ms: Date.now() - start
});
}
});
Wire AliveMCP to check /health/s3 every 60 seconds. The HeadBucket call is cheap and validates credentials, network path, and bucket existence in a single HTTP call. When short-lived IAM credentials (STS assume-role) expire, this endpoint surfaces the failure immediately rather than waiting for a file operation tool to fail in production.
Silent failure modes
| Failure | Symptom | Caught by process ping? | Detection |
|---|---|---|---|
| IAM credentials expired (STS token) | All S3 tool calls throw 403 ExpiredToken | No | /health/s3 HeadBucket probe |
| Bucket policy change (access revoked) | All S3 operations throw 403 AccessDenied | No | Same — HeadBucket returns 403 |
| S3 region endpoint mismatch | PermanentRedirect or slow redirects |
No | Monitor latency; set correct region in S3Client config |
| Object not found (key drift) | GetObject throws 404 NoSuchKey | No — tool call fails | HeadObject before GetObject; structured McpError on 404 |
| Bucket throttling (S3 request rate) | SDK retries 3×; tool slow or fails after retries | No | Track tool latency P95; log SlowDown errors |
Frequently asked questions
How do I use Cloudflare R2 instead of AWS S3?
R2 uses the same S3-compatible API. Set the endpoint in S3Client to https://<ACCOUNT_ID>.r2.cloudflarestorage.com and provide an R2 API token as the credentials (set AWS_ACCESS_KEY_ID to the R2 token ID and AWS_SECRET_ACCESS_KEY to the R2 secret). Set region: 'auto' — R2 doesn't use AWS regions. Do not set forcePathStyle: true for R2 (it uses virtual-hosted-style URLs). The main advantage of R2 is zero egress fees — if your MCP server downloads files frequently, R2 can eliminate a large cost category compared to AWS S3.
How do I handle multipart uploads for files over 100 MB?
Use the AWS SDK's @aws-sdk/lib-storage Upload class, which automatically splits large files into 5 MB parts and uploads them in parallel. Accept a stream or large base64 string in the tool, convert to a Readable stream, and pass it to new Upload({ client, params: { Bucket, Key, Body: stream } }).done(). For MCP tool parameters, base64-encoding a 500 MB file is impractical — a better pattern for large uploads is to generate a presigned upload URL with createPresignedPost and return it to the caller, who uploads directly to S3 without routing through the MCP server.
How should I scope IAM permissions for the MCP server?
Grant only the operations the server actually uses, scoped to the specific bucket and key prefix. A least-privilege policy for a read/write server might be: s3:PutObject, s3:GetObject, s3:HeadObject, s3:DeleteObject, s3:ListBucket (required for list operations), and s3:HeadBucket (required for the health check) — all restricted to arn:aws:s3:::your-bucket/uploads/* (plus arn:aws:s3:::your-bucket for HeadBucket and ListBucket). Never grant s3:* or allow access to buckets other than the one the server uses.
How do I generate presigned upload URLs so users can upload directly?
Use getSignedUrl(s3, new PutObjectCommand({ Bucket, Key, ContentType }), { expiresIn: 900 }) from @aws-sdk/s3-request-presigner. Return the URL and the expected key to the MCP caller. The caller can then PUT the file directly to S3 with the exact Content-Type header you specified — the signature is bound to that content type. Set a short expiry (15 minutes) and include the ContentLength in the presigned URL if you want to enforce a size limit on the upload. Note: presigned upload URLs can't enforce bucket policies that require server-side encryption — set a bucket policy that requires aws:SecureTransport: true and s3:x-amz-server-side-encryption: AES256 to enforce it at the bucket level.
Further reading
- MCP Server PDF Tools — storing and processing PDFs from S3 buckets
- MCP Server Image Processing — processing S3-stored images with Sharp
- MCP Server Binary Content — returning file data in MCP tool responses
- MCP Server Health Check — designing endpoints for uptime monitors
- MCP Server Secrets Management — secure credential storage for AWS keys