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

Know when your B2 application key is revoked before uploads fail

AliveMCP polls your /health/b2 endpoint every 60 seconds and alerts you the moment a deleted key or regional misconfiguration breaks your MCP tools — catching the credential issues that cost you $0 to fix if you catch them early.

Start monitoring free