Guide · Cloud Storage

MCP Tools for AWS S3 — presigned URLs, multipart upload, credential chain, lifecycle rules

Three S3 behaviours catch developers building MCP storage tools: presigned URLs inherit the signing credential's expiry ceiling — if you sign with a temporary role credential (e.g., EC2 instance profile or ECS task role), the presigned URL's maximum validity is bounded by the role session expiry (up to 12 hours), not the expiresIn value you pass; signing with an IAM user's long-term access key removes this ceiling; multipart uploads that are never completed or aborted accumulate storage costs indefinitely — S3 does not automatically clean them up, so every bucket used for multipart uploads needs a lifecycle rule with AbortIncompleteMultipartUploads: { DaysAfterInitiation: 7 } or equivalent; and path-style endpoint URLs are being deprecated — AWS deprecated path-style access (s3.region.amazonaws.com/bucket) in favour of virtual-hosted style (bucket.s3.region.amazonaws.com), but S3-compatible services and some VPC endpoints still require forcePathStyle: true, so targeting the wrong style returns confusing NoSuchBucket errors on valid buckets.

TL;DR

Client: new S3Client({ region, credentials }). Upload <5MB: PutObjectCommand. Upload >5MB: Upload from @aws-sdk/lib-storage for auto multipart. Presigned URL: getSignedUrl(client, new GetObjectCommand({Bucket,Key}), { expiresIn: 3600 }). List: ListObjectsV2Command with ContinuationToken for pagination. Health: HeadBucketCommand — 200 = accessible, 403 = exists but no permission, 404 = does not exist.

Credential chain and client setup

The AWS SDK resolves credentials in a fixed order: explicit credentials in code → AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars → shared credentials file (~/.aws/credentials) → AWS SSO → EC2 instance metadata → ECS task role → Cognito. For MCP servers deployed on ECS or Lambda, omit explicit credentials entirely and let the SDK pick up the task role automatically — it handles rotation transparently. For local development, set env vars or use a named profile with AWS_PROFILE.

import { S3Client, PutObjectCommand, GetObjectCommand,
         HeadBucketCommand, ListObjectsV2Command,
         DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { Upload } from '@aws-sdk/lib-storage';
import { Readable } from 'stream';

// Let the credential chain resolve automatically — do not hardcode keys
const s3 = new S3Client({
  region: process.env.AWS_REGION ?? 'us-east-1',
  // forcePathStyle: true  // uncomment for MinIO or S3-compatible endpoints
});

// For explicit credentials (local dev or CI), pass them directly
const s3WithCreds = new S3Client({
  region: 'us-east-1',
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
    sessionToken: process.env.AWS_SESSION_TOKEN,  // required if using STS temporary creds
  },
});

S3 provides strong read-after-write consistency for all object operations since December 2020 — PUTs, GETs, DELETEs, and list operations are all immediately consistent. You no longer need to add delays or retry loops after a write before reading the same key. This applies to all S3 regions and storage classes including S3-IA and S3-Glacier for tier transitions (though Glacier retrieval latency is separate).

Uploading objects — PutObject vs multipart

For objects under 5 MB, use PutObjectCommand directly. For objects 5 MB to 5 GB, multipart upload is optional but recommended — it allows parallel part uploads and resumable transfers. For objects over 5 GB, multipart is mandatory (PutObject rejects them). The Upload helper from @aws-sdk/lib-storage automatically chooses multipart when the body exceeds the partSize threshold (default 5 MB) and handles part parallelism and retry.

const BUCKET = process.env.S3_BUCKET!;

// Small object — direct PutObject
async function putSmallObject(key: string, body: Buffer | string, contentType: string) {
  await s3.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body: body,
    ContentType: contentType,
    // Set metadata as lowercase headers — S3 normalises them to lowercase
    Metadata: { 'uploaded-by': 'mcp-server', 'session-id': 'sess_123' },
  }));
}

// Large object — auto-multipart via Upload helper
async function putLargeObject(
  key: string,
  stream: Readable,
  contentType: string,
  onProgress?: (loaded: number, total?: number) => void
) {
  const upload = new Upload({
    client: s3,
    params: {
      Bucket: BUCKET,
      Key: key,
      Body: stream,
      ContentType: contentType,
    },
    partSize: 10 * 1024 * 1024,  // 10 MB parts
    queueSize: 4,                  // 4 parallel part uploads
  });

  if (onProgress) {
    upload.on('httpUploadProgress', (progress) => {
      onProgress(progress.loaded ?? 0, progress.total);
    });
  }

  const result = await upload.done();
  return result.Location;
}

// MCP tool wrapping the upload
async function uploadFileTool(args: { key: string; base64Content: string; contentType: string }) {
  const buffer = Buffer.from(args.base64Content, 'base64');
  if (buffer.length < 5 * 1024 * 1024) {
    await putSmallObject(args.key, buffer, args.contentType);
  } else {
    const stream = Readable.from(buffer);
    await putLargeObject(args.key, stream, args.contentType);
  }
  return { bucket: BUCKET, key: args.key, size: buffer.length };
}

Incomplete multipart uploads — parts that were uploaded but the upload was never completed or aborted — are not visible in object listings but do incur storage charges. Add a bucket lifecycle rule to abort them automatically:

import { PutBucketLifecycleConfigurationCommand } from '@aws-sdk/client-s3';

async function ensureMultipartCleanupRule(bucketName: string) {
  await s3.send(new PutBucketLifecycleConfigurationCommand({
    Bucket: bucketName,
    LifecycleConfiguration: {
      Rules: [{
        ID: 'abort-incomplete-multipart-uploads',
        Status: 'Enabled',
        Filter: { Prefix: '' },  // applies to all objects
        AbortIncompleteMultipartUploads: { DaysAfterInitiation: 7 },
      }],
    },
  }));
}

Presigned URLs — upload and download

Presigned URLs let clients upload or download directly to S3 without routing through your MCP server. The URL encodes the bucket, key, HTTP method, expiry, and a signature computed from the signing credential. The maximum expiry when signing with temporary credentials (STS, instance roles, task roles) is the remaining session duration — not the expiresIn parameter. If you request a 7-day presigned URL but the role session expires in 2 hours, the URL expires in 2 hours regardless of the parameter value, and the error manifests as an expired signature with no clear message about the root cause.

// Presigned download URL — GET (default)
async function getDownloadUrl(key: string, expiresInSeconds = 3600): Promise {
  return getSignedUrl(
    s3,
    new GetObjectCommand({ Bucket: BUCKET, Key: key }),
    { expiresIn: expiresInSeconds }
  );
}

// Presigned upload URL — PUT
async function getUploadUrl(
  key: string,
  contentType: string,
  expiresInSeconds = 900  // 15 minutes is reasonable for browser uploads
): Promise {
  return getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: BUCKET,
      Key: key,
      ContentType: contentType,  // client must send matching Content-Type header
    }),
    { expiresIn: expiresInSeconds }
  );
}

// Presigned POST — allows size and content-type restrictions at the policy level
// Use PutObject presigned URL for simplicity; use POST for browser form uploads with policy
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';

async function getPresignedPost(key: string, maxSizeMB: number) {
  return createPresignedPost(s3, {
    Bucket: BUCKET,
    Key: key,
    Conditions: [
      ['content-length-range', 1, maxSizeMB * 1024 * 1024],
      ['starts-with', '$Content-Type', 'image/'],
    ],
    Expires: 600,  // 10 minutes
  });
  // Returns { url, fields } — fields must be included as form fields before the file
}

For browser-based direct uploads (without CORS restrictions), configure S3 CORS on the bucket to allow PUT from your frontend origin. The presigned URL approach avoids storing credentials in the browser — the MCP server generates the URL and passes it to the agent or frontend, which then uploads directly. The MCP server never touches the object bytes.

Listing objects and pagination

Always use ListObjectsV2Command (not the deprecated V1). S3 returns at most 1,000 objects per page. Pagination uses ContinuationToken from the previous response — not page numbers. For large buckets, use a Prefix filter and a delimiter (/) to simulate directory-style listing without fetching all keys.

// Paginated list — handles >1000 objects
async function listAllObjects(prefix: string): Promise {
  const keys: string[] = [];
  let continuationToken: string | undefined;

  do {
    const response = await s3.send(new ListObjectsV2Command({
      Bucket: BUCKET,
      Prefix: prefix,
      MaxKeys: 1000,
      ContinuationToken: continuationToken,
    }));

    for (const obj of response.Contents ?? []) {
      if (obj.Key) keys.push(obj.Key);
    }

    continuationToken = response.NextContinuationToken;
  } while (continuationToken);

  return keys;
}

// Directory-style listing (top-level "folders" and direct objects)
async function listDirectory(prefix: string) {
  const response = await s3.send(new ListObjectsV2Command({
    Bucket: BUCKET,
    Prefix: prefix.endsWith('/') ? prefix : prefix + '/',
    Delimiter: '/',    // groups keys by prefix — returns CommonPrefixes for "folders"
    MaxKeys: 200,
  }));

  return {
    folders: (response.CommonPrefixes ?? []).map(p => p.Prefix),
    files: (response.Contents ?? []).map(obj => ({
      key: obj.Key,
      size: obj.Size,
      lastModified: obj.LastModified,
      storageClass: obj.StorageClass,
    })),
    truncated: response.IsTruncated ?? false,
  };
}

Health probe — bucket accessibility and credential validity

HeadBucketCommand is the minimal S3 health check — it returns the bucket's region without fetching any objects and uses minimal request tokens. The response codes distinguish three distinct failure modes: 200 (accessible), 301 (redirect — bucket is in a different region than the client is configured for), 403 (bucket exists but credentials lack s3:GetBucketLocation), and 404 (bucket does not exist). A 403 from HeadBucket does not mean the credentials are invalid — separate credential probes and bucket permission probes to diagnose correctly.

import { HeadBucketCommand, HeadObjectCommand, S3ServiceException } from '@aws-sdk/client-s3';
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';

async function checkS3Health(bucketName: string): Promise {
  const results: Record = {};

  // 1. Verify credentials are valid and identify the caller
  try {
    const sts = new STSClient({ region: process.env.AWS_REGION ?? 'us-east-1' });
    const identity = await sts.send(new GetCallerIdentityCommand({}));
    results.credentialsValid = true;
    results.callerArn = identity.Arn;
    results.accountId = identity.Account;
  } catch (err) {
    results.credentialsValid = false;
    results.credentialError = String(err);
    return results;  // no point checking bucket if creds are broken
  }

  // 2. Check bucket accessibility
  try {
    await s3.send(new HeadBucketCommand({ Bucket: bucketName }));
    results.bucketAccessible = true;
  } catch (err) {
    const s3err = err as S3ServiceException;
    results.bucketAccessible = false;
    results.bucketStatusCode = s3err.$metadata?.httpStatusCode;
    // 301 = wrong region, 403 = no permission, 404 = not found
    results.bucketError = s3err.name;
  }

  // 3. Verify write permission with a small test object (optional — only if needed)
  if (results.bucketAccessible) {
    try {
      const testKey = `.health-check-${Date.now()}`;
      await s3.send(new PutObjectCommand({
        Bucket: bucketName,
        Key: testKey,
        Body: 'ok',
        ContentType: 'text/plain',
      }));
      await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: testKey }));
      results.writePermission = true;
    } catch (err) {
      results.writePermission = false;
      results.writeError = String(err);
    }
  }

  return results;
}

		

For MCP servers that only need read access, skip the write permission check — it creates and immediately deletes an object on every health probe, which accumulates cost in high-frequency monitoring. AliveMCP monitors S3-backed MCP endpoints by checking the health probe response shape, the read latency trend, and whether the presigned URL generation succeeds — not by actually uploading test objects.

Virtual-hosted style vs path-style and S3-compatible APIs

AWS S3 deprecated path-style bucket addressing (https://s3.amazonaws.com/bucket/key) in favour of virtual-hosted style (https://bucket.s3.amazonaws.com/key). New buckets created after the deprecation announcement only work with virtual-hosted style on AWS. However, S3-compatible services — MinIO, Cloudflare R2, Backblaze B2, LocalStack — and some AWS VPC interface endpoints require path-style. Set forcePathStyle: true in the client config when targeting these endpoints.

// S3-compatible endpoint (MinIO, LocalStack, R2 via S3 API)
const s3Compatible = new S3Client({
  region: 'us-east-1',  // required by SDK even if the service ignores it
  endpoint: process.env.S3_ENDPOINT ?? 'http://localhost:9000',
  forcePathStyle: true,  // required for path-style services
  credentials: {
    accessKeyId: process.env.MINIO_ROOT_USER ?? 'minioadmin',
    secretAccessKey: process.env.MINIO_ROOT_PASSWORD ?? 'minioadmin',
  },
});

// Detect endpoint type at startup and warn if misconfigured
async function detectEndpointStyle(client: S3Client, bucket: string) {
  try {
    await client.send(new HeadBucketCommand({ Bucket: bucket }));
    return 'ok';
  } catch (err) {
    const s3err = err as S3ServiceException;
    if (s3err.$metadata?.httpStatusCode === 301) {
      return 'wrong-region-or-style';  // bucket exists in a different region/style
    }
    throw err;
  }
}

Related guides