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
- MCP Server AWS S3 — GetObject, PutObject, presigned URLs, and health monitoring
- MCP Server MinIO — S3-compatible self-hosted storage and /minio/health/live probe
- MCP Server Backblaze B2 — S3-compatible API tools and /health/b2 for uptime monitoring
- MCP Server Health Check — designing endpoints for uptime monitors
- MCP Tools for Cloud Storage — SDK choice, auth models, and health probe patterns across GCS, Azure, R2, MinIO, and B2