Guide · MCP Cloud Storage Integration
MCP Server Backblaze B2 — S3-compatible API tools, application keys, and /health/b2
Backblaze B2 is an object store known for its low per-GB pricing and its S3-compatible API — letting MCP servers reuse the AWS SDK with a simple endpoint override. This guide covers B2 application key scoping (the critical security difference from root credentials), the correct S3-compatible endpoint format, building list_objects, get_object, put_object, and delete_object tools, the difference between presigned S3 URLs and B2's native friendly download URLs, and wiring a /health/b2 endpoint via HeadBucketCommand for AliveMCP monitoring.
TL;DR
Use @aws-sdk/client-s3 with B2's S3-compatible endpoint (https://s3.<region>.backblazeb2.com). The <region> segment is your bucket's region code — find it in the B2 dashboard under Bucket Settings. Always create Application Keys (not the master key) scoped to the specific bucket your MCP server uses. Unlike MinIO, B2 does not require forcePathStyle: true — it supports virtual-hosted-style URLs. Health probe: HeadBucketCommand verifies the application key is valid and has access to the bucket; a 403 means the key was deleted or its permissions were changed.
SDK setup and B2 application key authentication
B2 distinguishes between the master key (full account access) and application keys (scoped access). Never use the master key in MCP server code — create a dedicated application key restricted to the bucket your server needs.
import { S3Client } from '@aws-sdk/client-s3';
// STEP 1: Find your bucket's region in the B2 dashboard.
// It's shown in Bucket Settings as something like "us-west-004" or "eu-central-003"
const B2_REGION = process.env.B2_REGION!; // e.g., "us-west-004"
const BUCKET_NAME = process.env.B2_BUCKET_NAME!;
if (!B2_REGION || !BUCKET_NAME) {
throw new Error('B2_REGION and B2_BUCKET_NAME are required');
}
// B2 S3-compatible endpoint format: https://s3.{region}.backblazeb2.com
const b2 = new S3Client({
region: B2_REGION, // Must match the bucket's actual region
endpoint: `https://s3.${B2_REGION}.backblazeb2.com`,
credentials: {
// Application Key ID — starts with a long numeric string
accessKeyId: process.env.B2_APPLICATION_KEY_ID!,
// Application Key — the secret portion (shown once at creation)
secretAccessKey: process.env.B2_APPLICATION_KEY!,
},
// B2 supports virtual-hosted-style URLs — forcePathStyle is NOT required
maxAttempts: 3
});
export { b2, BUCKET_NAME, B2_REGION };
B2 application key permission types:
| Capability | S3 equivalent | Grant to MCP servers |
|---|---|---|
| readFiles | s3:GetObject | Yes — for get_object and generate_download_url |
| writeFiles | s3:PutObject | Yes — for put_object |
| deleteFiles | s3:DeleteObject | Yes — for delete_object (with confirm guard) |
| listFiles | s3:ListBucket | Yes — for list_objects |
| listBuckets | s3:ListAllMyBuckets | Not needed — lock the bucket in server config |
| readBucketEncryption | s3:GetBucketEncryption | Optional — for HeadBucket in health probe |
| writeBucketEncryption | s3:PutBucketEncryption | No — not needed for MCP server operations |
Create the application key in the B2 dashboard under App Keys → Add a New Application Key. Set "Restrict to Bucket" to the specific bucket name. Set "Type of Access" to "Read and Write". Copy the applicationKey value immediately — it's only shown once. The keyID (which maps to accessKeyId) is always retrievable from the dashboard.
Core B2 tool patterns
import {
GetObjectCommand,
PutObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand
} from '@aws-sdk/client-s3';
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { b2, BUCKET_NAME } from './b2-client.js';
function validateKey(key: string): void {
if (!key || key.includes('..') || key.startsWith('/') || /[\0\r\n]/.test(key)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid B2 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 b2.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 b2.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
if (!response.Body) {
throw new McpError(ErrorCode.InternalError, 'Empty response from B2');
}
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' || err.$metadata?.httpStatusCode === 404) {
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 b2.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);
// B2 versioning note: if the bucket has versioning enabled,
// DeleteObject creates a delete marker (hides the file but keeps history).
// Use ListObjectVersions + DeleteObjects to permanently remove all versions.
await b2.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
return {
content: [{ type: 'text', text: JSON.stringify({ deleted: key }) }]
};
}
);
B2's per-transaction pricing model differs from S3's Class A/B distinction but maps similarly in practice: Class A operations (bucket creation, listing, uploads, deletes) are charged per 1,000 calls; Class B operations (downloads) are free for the first 1 GB/day and then charged per GB. Unlike S3 where egress is charged separately, B2 bundles download bandwidth into Class B pricing. For MCP servers that primarily serve reads, B2's bandwidth-included pricing for Class B is a significant cost difference from S3.
Download URLs: presigned S3 URLs vs B2 friendly URLs
B2 supports two download URL formats: S3-compatible presigned URLs (generated with the AWS SDK) and B2 native "friendly" URLs for public buckets. Use presigned URLs for private buckets; friendly URLs for public access.
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// ---- generate_download_url (presigned, works for private buckets) ----
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 object exists
try {
await b2.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
} catch (err: any) {
if (err.$metadata?.httpStatusCode === 404) {
throw new McpError(ErrorCode.InvalidParams, `Object not found: ${key}`);
}
throw err;
}
const command = new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key });
const url = await getSignedUrl(b2, command, { expiresIn: expires_in_seconds });
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 })
}]
};
}
);
B2 friendly URL format for public buckets (no presigning needed):
// Public bucket: objects downloadable without presigning
// B2 friendly URL: https://{bucket}.s3.{region}.backblazeb2.com/{key}
// OR: https://f{clusterNum}.backblazeb2.com/file/{bucket}/{key}
function getPublicDownloadUrl(key: string): string {
// S3-compatible public URL (works if bucket is set to "Public")
return `https://${BUCKET_NAME}.s3.${B2_REGION}.backblazeb2.com/${encodeURIComponent(key)}`;
}
// Tip: pair a Cloudflare proxy in front of your B2 public bucket:
// - Zero egress from B2 to Cloudflare (Bandwidth Alliance)
// - Use Cloudflare's CDN for caching and DDoS protection
// - Set your Cloudflare domain as a CNAME to the B2 bucket URL
| URL type | Requires private key | Expiry | Works with private buckets |
|---|---|---|---|
| S3 presigned URL | Application key | Up to 7 days | Yes |
| B2 friendly URL (public) | No | No expiry | No — public only |
| B2 download authorization | Application key (native B2 API) | 1 hour max | Yes — native B2 API, not S3-compatible |
Health endpoint: /health/b2
import express from 'express';
import { HeadBucketCommand } from '@aws-sdk/client-s3';
import { b2, BUCKET_NAME, B2_REGION } from './b2-client.js';
const app = express();
app.get('/health/b2', async (_req, res) => {
const start = Date.now();
try {
await b2.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
const latencyMs = Date.now() - start;
res.json({
status: 'ok',
bucket: BUCKET_NAME,
region: B2_REGION,
latency_ms: latencyMs
});
} 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,
region: B2_REGION,
elapsed_ms: latencyMs,
// Common B2 errors:
// 403 — application key deleted, wrong key, or bucket-scope mismatch
// 404 — bucket does not exist (check bucket name and region)
// ECONNREFUSED / ENOTFOUND — wrong region in endpoint URL
});
}
});
app.listen(3001);
| B2 failure mode | HeadBucket response | Detected |
|---|---|---|
| Application key deleted | 403 Forbidden | Yes — returns 503 |
| Key capabilities reduced | 403 Forbidden | Yes — key valid but HeadBucket not permitted |
| Bucket deleted | 404 Not Found | Yes — returns 503 |
| Wrong region in endpoint | 301 Redirect or DNS failure | Yes — SDK follows redirect or throws ENOTFOUND |
| Key scoped to different bucket | 403 Forbidden | Yes — key exists but not for this bucket |
| B2 service outage | Timeout or 5xx | Yes — returns 503 after SDK timeout |
The most common B2 misconfiguration is using the wrong region in the endpoint URL. If your bucket is in us-west-004 but you set B2_REGION=us-east-005, requests go to a different B2 cluster. B2 returns a 301 redirect in some cases (which the SDK may follow incorrectly) or a 403. Always verify the region from the B2 dashboard's Bucket Settings tab — it's listed as the "Endpoint" field, e.g., s3.us-west-004.backblazeb2.com. Extract the region from between s3. and .backblazeb2.com.
Object versioning and the Bandwidth Alliance
B2 supports object versioning (keep all versions of each object), which interacts with delete operations differently from S3. When versioning is enabled and you call DeleteObject, B2 creates a delete marker — the object appears deleted but previous versions are retained. To permanently delete all versions, you need to list and delete each version individually using ListObjectVersions and DeleteObjects.
import { ListObjectVersionsCommand, DeleteObjectsCommand } from '@aws-sdk/client-s3';
// Permanently delete all versions of a key (when versioning is enabled)
async function hardDeleteAllVersions(key: string): Promise<void> {
const response = await b2.send(new ListObjectVersionsCommand({
Bucket: BUCKET_NAME,
Prefix: key // exact key match when prefix is the full key
}));
const objectsToDelete = [
...(response.Versions ?? []).map(v => ({ Key: v.Key!, VersionId: v.VersionId })),
...(response.DeleteMarkers ?? []).map(dm => ({ Key: dm.Key!, VersionId: dm.VersionId }))
].filter(o => o.Key === key); // exact match only
if (objectsToDelete.length === 0) return;
await b2.send(new DeleteObjectsCommand({
Bucket: BUCKET_NAME,
Delete: { Objects: objectsToDelete, Quiet: true }
}));
}
The Bandwidth Alliance note for B2: Backblaze and Cloudflare are Bandwidth Alliance partners, which means downloads from B2 to Cloudflare's network are free. If you put a Cloudflare CDN zone in front of your B2 public bucket, all downloads via Cloudflare are zero egress cost — even for high-volume workloads. Configure it by adding a CNAME record pointing your domain to the B2 bucket endpoint and enabling Cloudflare proxy. This is the primary reason teams choose B2 + Cloudflare over S3 + CloudFront — the egress cost difference at scale is substantial.
Frequently asked questions
Should I use the B2 native API or the S3-compatible API?
For MCP server tool operations, the S3-compatible API is the right choice. It lets you reuse existing AWS SDK tool code, is well-documented, and supports all standard object operations (list, get, put, delete, presigned URLs, multipart uploads). The native B2 API (b2_upload_file, b2_download_file_by_name, etc.) has one advantage: download authorization tokens (via b2_get_download_authorization) which provide temporary access without the full S3 presigned URL overhead. But unless you're already on the native API for other reasons, don't introduce two API surfaces for the same bucket. Stick with S3-compatible and use presigned URLs for temporary access.
What happens when the B2 free daily download allowance is exceeded?
B2 provides 1 GB of free outbound bandwidth per day (this is per-account, not per-bucket). Beyond that, bandwidth is billed at $0.01 per GB — approximately 10× cheaper than S3 standard egress ($0.09/GB). For MCP servers that return file content in tool responses, track cumulative download bytes in your analytics. If you expect to exceed 1 GB/day, budget the overage cost. To reduce direct downloads via your MCP server, return presigned URLs instead of file contents — the download then happens directly between the caller and B2, charged to the same account but not routed through your server's bandwidth.
Can I use B2 and Cloudflare R2 simultaneously from the same MCP server?
Yes — since both use the S3-compatible API with different endpoint configurations, you can maintain two S3 client instances. A common pattern is: B2 for persistent storage (lower per-GB cost, built-in versioning) and R2 for content delivery to end users (zero egress, Cloudflare CDN integration). Your MCP server writes to B2, then the workflow generates a presigned URL pointing to B2 for the caller. For high-traffic read scenarios, replicate objects from B2 to R2 via rclone or a background copy process, and generate R2 presigned URLs for end-user downloads. This takes advantage of R2's zero egress while keeping B2 as the durable, versioned source of truth.
How do I find which region my B2 bucket is in?
The region is visible in two places in the B2 dashboard. First, in the Buckets list — there's an "Endpoint" column showing the full S3-compatible endpoint URL (s3.us-west-004.backblazeb2.com). Extract the region as the middle segment. Second, in Bucket Details — click on a bucket name and look for "S3 Endpoint" in the settings panel. The region in the endpoint URL is what you must use as the region option in your S3Client config and as the middle segment of the endpoint URL. Using a different region causes 301 redirect loops or 403 errors — the application key is cryptographically tied to its issuing region cluster, and credentials issued in one region are not valid in another.
Further reading
- MCP Server AWS S3 — GetObject, PutObject, presigned URLs, and health monitoring
- MCP Server Cloudflare R2 — S3-compatible storage tools and /health/r2 via HeadBucket
- MCP Server MinIO — S3-compatible self-hosted storage and /minio/health/live probe
- MCP Server GCP Cloud Storage — list, download, upload, signed URLs, and /health/gcs
- MCP Tools for Cloud Storage — SDK choice, auth models, and health probe patterns across GCS, Azure, R2, MinIO, and B2