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

Know when your S3 credentials expire

AliveMCP monitors your /health/s3 endpoint and alerts you the moment IAM credentials expire or S3 access is revoked.

Start monitoring free