Guide · MCP Cloud Storage Integration

MCP Server Cloudflare R2 — S3-compatible storage tools, presigned URLs, and /health/r2

Cloudflare R2 is an S3-compatible object store with zero egress fees — making it a natural choice for MCP servers that return large files or generate high-volume presigned download URLs. Unlike GCS and Azure Blob, R2 reuses the AWS S3 SDK (@aws-sdk/client-s3) with a simple endpoint override, so existing S3 tool code ports with minimal changes. This guide covers R2 API token setup, the required endpoint and credential format, list_objects, get_object, put_object, and delete_object patterns, presigned URL generation, and wiring a /health/r2 endpoint via HeadBucketCommand.

TL;DR

Configure the AWS S3 SDK with R2's endpoint (https://<ACCOUNT_ID>.r2.cloudflarestorage.com), an R2 API token as the access/secret key pair, and region: 'auto'. No new SDK needed — all @aws-sdk/client-s3 operations work against R2. Key differences from S3: no object versioning by default, no server-side encryption with customer keys (SSE-C), and presigned URLs must use signature version v4. Health probe: HeadBucketCommand verifies bucket access and credential validity — a 403 means the token was revoked or the bucket was deleted from the account.

SDK setup and R2 authentication

R2 exposes the S3 XML API at a per-account endpoint. The only configuration changes from standard S3 are the endpoint URL, region set to 'auto', and the R2 API token credentials. Create R2 API tokens in the Cloudflare dashboard under R2 → Manage R2 API Tokens.

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

const ACCOUNT_ID = process.env.CLOUDFLARE_ACCOUNT_ID!;
const BUCKET_NAME = process.env.R2_BUCKET_NAME!;

if (!ACCOUNT_ID || !BUCKET_NAME) {
  throw new Error('CLOUDFLARE_ACCOUNT_ID and R2_BUCKET_NAME are required');
}

// R2 reuses the AWS SDK — only the endpoint and credentials differ
const r2 = new S3Client({
  region: 'auto',  // R2 doesn't use AWS regions; 'auto' suppresses region-mismatch errors
  endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    // R2 API token — created in Cloudflare dashboard, NOT AWS credentials
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
  // R2 does NOT support path-style URLs for the S3 API
  // forcePathStyle is NOT needed (R2 uses virtual-hosted-style by default)
  maxAttempts: 3
});

export { r2, ACCOUNT_ID, BUCKET_NAME };

R2 API token permission scopes in the Cloudflare dashboard:

Token permission What it allows Use when
Object Read GetObject, HeadObject, ListObjects Read-only MCP servers
Object Read & Write All object operations including Put and Delete Full read-write MCP servers
Admin Read ListBuckets, GetBucketAcl, HeadBucket Needed for health probe via HeadBucket
Admin Read & Write Create/delete buckets, manage CORS, lifecycle Infrastructure management — not needed for tool ops

Scope R2 API tokens to specific buckets when possible — the Cloudflare dashboard allows restricting a token to one or more buckets. A token scoped to my-mcp-bucket cannot access my-other-bucket even if the same account owns both. This is equivalent to S3's bucket-level IAM policy without the IAM JSON complexity.

Core R2 tool patterns

All S3 SDK commands work against R2 unchanged. The only code difference is the r2 client instance (with the R2 endpoint) instead of the standard AWS S3 client.

import {
  GetObjectCommand,
  PutObjectCommand,
  DeleteObjectCommand,
  ListObjectsV2Command,
  HeadObjectCommand
} from '@aws-sdk/client-s3';
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { r2, BUCKET_NAME } from './r2-client.js';

function validateKey(key: string): void {
  if (!key || key.includes('..') || key.startsWith('/') || /[\0\r\n]/.test(key)) {
    throw new McpError(ErrorCode.InvalidParams, `Invalid R2 key: ${key}`);
  }
}

async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of stream) {
    chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
  }
  return Buffer.concat(chunks);
}

// ---- 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 r2.send(new ListObjectsV2Command({
      Bucket: BUCKET_NAME,
      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)
      }]
    };
  }
);

// ---- 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 r2.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));

      if (!response.Body) {
        throw new McpError(ErrorCode.InternalError, 'Empty response from R2');
      }

      const buffer = await streamToBuffer(response.Body as NodeJS.ReadableStream);
      const content = encoding === 'base64'
        ? buffer.toString('base64')
        : buffer.toString('utf-8');

      return {
        content: [{
          type: 'text',
          text: JSON.stringify({
            key,
            content,
            encoding,
            content_type: response.ContentType,
            content_length: response.ContentLength,
            last_modified: response.LastModified?.toISOString(),
            etag: response.ETag
          })
        }]
      };
    } catch (err: any) {
      if (err.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 r2.send(new PutObjectCommand({
      Bucket: BUCKET_NAME,
      Key: key,
      Body: body,
      ContentType: content_type,
      ContentLength: body.length
    }));

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ key, etag: response.ETag, bytes: body.length })
      }]
    };
  }
);

// ---- delete_object ----
server.tool(
  'delete_object',
  {
    key: z.string().min(1).max(1024),
    confirm: z.literal(true)
  },
  async ({ key }) => {
    validateKey(key);
    await r2.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
    return {
      content: [{ type: 'text', text: JSON.stringify({ deleted: key }) }]
    };
  }
);

R2 returns HTTP 200 (not 204) for successful DeleteObject requests even if the object didn't exist — the same behavior as S3. This means delete_object won't throw if the key is already gone. If idempotency matters (you need to know whether the object actually existed), call HeadObjectCommand before deleting, or use a separate exists check.

Presigned URLs and public bucket access

R2 presigned URLs use the same @aws-sdk/s3-request-presigner package as S3, but with the R2 client. The signed URL's host resolves to Cloudflare's network, so downloads benefit from Cloudflare's global CDN with zero egress fees to the caller.

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)
  },
  async ({ key, expires_in_seconds }) => {
    validateKey(key);

    // Verify the object exists before signing
    try {
      await r2.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
    } catch (err: any) {
      if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
        throw new McpError(ErrorCode.InvalidParams, `Object not found: ${key}`);
      }
      throw err;
    }

    const command = new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key });
    // R2 requires signingRegion: 'auto' to match the client configuration
    const url = await getSignedUrl(r2, command, {
      expiresIn: expires_in_seconds,
      signingRegion: 'auto'
    });

    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,
          // Note: downloads via this URL have zero egress cost from R2
          // (Bandwidth Alliance pricing — no egress fee when serving to end users)
        })
      }]
    };
  }
);

R2 also supports public bucket access via a custom domain bound in the Cloudflare dashboard — this is separate from presigned URLs. If the bucket has a public domain (e.g., files.example.com), public objects are available at https://files.example.com/{key} without any signing. Use presigned URLs only for private buckets where you need time-limited access without making the object permanently public.

Access method Use when Egress cost
Presigned URL Private bucket, temporary access for specific caller Zero (R2 to end user)
Public domain URL Public bucket, all objects permanently accessible Zero
R2.dev subdomain Development/testing — Cloudflare-provided public URL Zero (but rate-limited in dev)
Direct S3 API GetObject Server-to-server transfers, not for end users Zero (no egress from R2)

Health endpoint: /health/r2

import express from 'express';
import { HeadBucketCommand } from '@aws-sdk/client-s3';
import { r2, BUCKET_NAME } from './r2-client.js';

const app = express();

app.get('/health/r2', async (_req, res) => {
  const start = Date.now();
  try {
    await r2.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
    const latencyMs = Date.now() - start;

    res.json({
      status: 'ok',
      bucket: BUCKET_NAME,
      latency_ms: latencyMs,
      note: 'R2 does not return region in HeadBucket — latency reflects Cloudflare edge proximity'
    });
  } catch (err: any) {
    const latencyMs = Date.now() - start;
    const statusCode = err.$metadata?.httpStatusCode;

    res.status(503).json({
      status: 'error',
      error: err.name ?? err.message,
      http_status: statusCode,
      elapsed_ms: latencyMs,
      // Common R2 errors:
      // 403 — API token revoked or wrong access key
      // 404 — bucket does not exist in this Cloudflare account
      // 403 (token scope) — token exists but lacks HeadBucket permission
    });
  }
});

app.listen(3001);
R2 failure mode HeadBucket response Detected
API token revoked 403 Forbidden Yes — returns 503
Bucket deleted 404 Not Found Yes — returns 503
Wrong account ID in endpoint 403 Forbidden Yes — endpoint resolves but credentials denied
Token scoped to wrong bucket 403 Forbidden Yes — token is valid but bucket access denied
Cloudflare network issue Timeout or 5xx Yes — returns 503 after timeout

R2 HeadBucket responses don't include a x-amz-bucket-region header (unlike S3) — this is expected behavior, not an error. If your SDK raises a NoSuchBucket error with an HTTP 301, it means the SDK is trying to follow a region redirect that doesn't apply to R2. Fix it by ensuring region: 'auto' is set in the client config and that you're not passing an explicit AWS region like 'us-east-1'.

Key differences from AWS S3

Feature AWS S3 Cloudflare R2
Object versioning Supported — enable per bucket Not supported — no versioning or delete markers
Server-side encryption SSE-S3, SSE-KMS, SSE-C Automatic SSE with Cloudflare-managed keys — no SSE-C or SSE-KMS
Egress fees Charged per GB to the internet Zero egress fees to all destinations
Request pricing Per-request charges (Class A/B) Per-request charges (Class A/B — similar structure)
Lifecycle rules Full lifecycle policy support Supported for object expiration (not tiering)
Presigned URL max lifetime 7 days 7 days (same)
Multipart upload Supported Supported (same S3 API)
Object locking (WORM) Supported Not supported

Frequently asked questions

Can I reuse existing S3 MCP tool code for R2 without changes?

Almost entirely. The only changes needed are: (1) create an S3Client with the R2 endpoint URL and credentials instead of AWS credentials; (2) set region: 'auto'; (3) remove any S3-specific features you're using that R2 doesn't support (versioning, SSE-C, object locking). Operations — GetObjectCommand, PutObjectCommand, ListObjectsV2Command, HeadBucketCommand, presigned URLs — all work identically. The signingRegion: 'auto' option in getSignedUrl() is optional but prevents region mismatch warnings in some SDK versions.

What is the Bandwidth Alliance and how does it affect MCP server costs?

The Bandwidth Alliance is a partnership between Cloudflare and major cloud providers (including AWS, GCP, Azure, and others) to waive egress fees for traffic transferred between members. When you serve R2 objects through Cloudflare's CDN or to Cloudflare Workers, the egress is free regardless of data volume. For MCP servers hosted on AWS or GCP that read R2 objects and return them in tool responses, the egress is also free — traffic from R2 to AWS/GCP infrastructure is covered by the Alliance. The practical implication: if your MCP tools handle large files frequently (ML model weights, large datasets, generated media), R2's zero-egress pricing can significantly reduce costs compared to S3 where egress fees apply at $0.09/GB.

How do I migrate existing S3 data to R2?

Use Cloudflare's Sippy feature for incremental migration: Sippy copies objects from your S3 bucket to R2 on first access — no bulk migration needed. Configure Sippy in the R2 dashboard by providing your S3 bucket name and AWS credentials. Alternatively, use rclone for a one-time bulk copy: rclone copy s3:source-bucket r2:dest-bucket --transfers 32 --progress. Configure rclone with your R2 endpoint as a custom S3-compatible provider. For ongoing dual-write during migration, your MCP server can write to both S3 and R2 simultaneously by maintaining two S3Client instances (one with AWS credentials, one with R2 credentials) and awaiting Promise.all([s3Put, r2Put]).

How do I set up lifecycle rules to auto-expire temporary uploads in R2?

R2 supports S3-compatible lifecycle rules for object expiration. Set them using PutBucketLifecycleConfigurationCommand from the S3 SDK — the same command works against R2. For example, to expire objects in a tmp/ prefix after 24 hours: await r2.send(new PutBucketLifecycleConfigurationCommand({ Bucket: BUCKET_NAME, LifecycleConfiguration: { Rules: [{ ID: 'expire-tmp', Status: 'Enabled', Filter: { Prefix: 'tmp/' }, Expiration: { Days: 1 } }] } })). This is useful for MCP servers that write intermediate outputs to R2 as a handoff between agents — the tmp prefix auto-cleans without requiring explicit delete calls from the MCP server. Note that R2 lifecycle rules support expiration only, not storage tier transitions (unlike S3 which supports S3-IA, Glacier transitions).

Further reading

Know when your R2 API token is revoked before uploads silently fail

AliveMCP monitors your /health/r2 endpoint every 60 seconds and alerts you the moment a token rotation or bucket deletion breaks your MCP tools — zero egress cost to check.

Start monitoring free