Cloud Storage · 2026-07-10 · Cloud Storage integrations arc

MCP Tools for Cloud Storage: Native SDK vs S3-Compatible API Reuse, Authentication Model Diversity, and Two-Layer Health Probes Across GCS, Azure Blob, R2, MinIO, and Backblaze B2

Object storage is the persistence layer for most agent outputs — files written by tools, images generated by LLMs, logs scraped from running services. Five platforms dominate the space: Google Cloud Storage, Azure Blob Storage, Cloudflare R2, MinIO, and Backblaze B2. They all expose broadly the same operations — list, get, put, delete, presigned URL — and they all look interchangeable from a distance. Three structural patterns separate the integrations that work from the ones that silently break: (1) SDK choice and the hidden configuration differences between S3-compatible providers; (2) authentication model diversity and the operational trade-offs each model introduces; (3) two-layer health probe design and why a single HeadBucket call fails to catch the most common failure modes on MinIO and Azure Blob. This is the synthesis of all five.

Five storage platforms, three patterns

The clearest way to see the three patterns is side by side. Every cell in this table contains a decision that, if made incorrectly, produces a silent failure:

Platform SDK Auth model Health probe Critical configuration
GCS @google-cloud/storage (native) ADC chain → IAM roles at bucket level bucket.getMetadata() IAM roles must be at bucket scope, not project
Azure Blob @azure/storage-blob + @azure/identity (native) DefaultAzureCredential chain → RBAC at container level containerClient.getProperties() Archive tier → 409 BlobArchived on any read
Cloudflare R2 @aws-sdk/client-s3 (S3-compatible) R2 API token as S3 credentials HeadBucketCommand region: 'auto'; no forcePathStyle
MinIO @aws-sdk/client-s3 (S3-compatible) Service account keys via mc CLI /minio/health/live + HeadBucketCommand forcePathStyle: true required; root creds never use
Backblaze B2 @aws-sdk/client-s3 (S3-compatible) Application Keys scoped to bucket HeadBucketCommand Region must match bucket's actual region from dashboard

GCS and Azure Blob use native SDKs built by the platform vendors. R2, MinIO, and B2 expose an S3-compatible XML API, so the same @aws-sdk/client-s3 package works for all three — with important differences in the endpoint URL, region value, path style, and credential format. The assumption that S3-compatible means configuration-identical is the most common source of silent failures in this category.

Pattern 1 — SDK choice: when to use a native SDK and when S3 compatibility is enough

The split between native SDKs (GCS, Azure Blob) and S3-compatible API reuse (R2, MinIO, B2) is not an accident. GCS and Azure Blob both expose features — signed URL generation via IAM, access tier management, blob snapshot semantics — that have no clean mapping to the S3 XML API. Their native SDKs exist because the native API surface is richer than what S3 compatibility provides.

R2, MinIO, and B2 made a different choice: they implemented the S3 XML API as their primary interface. That means existing S3 tool code runs against them with minimal changes. But "minimal changes" conceals three configuration traps that produce silent failures when you get them wrong.

The three S3-compatible configuration traps

Trap 1: forcePathStyle — MinIO requires it, R2 and B2 do not. The AWS SDK defaults to virtual-hosted-style URLs: https://bucket.endpoint.com/key. MinIO, when deployed without wildcard DNS, does not recognize virtual-hosted-style URLs — it expects path-style: https://endpoint.com/bucket/key. Without forcePathStyle: true, every S3 command against MinIO silently routes to a hostname that does not exist:

// MinIO — forcePathStyle: true is REQUIRED
const minio = new S3Client({
  endpoint: process.env.MINIO_ENDPOINT, // http://minio.internal:9000
  forcePathStyle: true,    // SDK sends: http://minio.internal:9000/bucket/key
  // Without this: SDK sends: http://bucket.minio.internal:9000/key — DNS NXDOMAIN
  region: 'us-east-1',     // MinIO ignores region; SDK requires a non-empty value
  credentials: { ... }
});

// R2 — forcePathStyle is NOT needed
const r2 = new S3Client({
  endpoint: `https://${ACCOUNT_ID}.r2.cloudflarestorage.com`,
  // forcePathStyle: NOT set — R2 uses virtual-hosted-style by default
  region: 'auto',
  credentials: { ... }
});

// B2 — forcePathStyle is NOT needed
const b2 = new S3Client({
  endpoint: `https://s3.${B2_REGION}.backblazeb2.com`,
  // forcePathStyle: NOT set — B2 supports virtual-hosted-style
  region: B2_REGION,  // Must match bucket's actual region from B2 dashboard
  credentials: { ... }
});

Trap 2: the region value — R2 uses 'auto', MinIO accepts anything, B2 must match the dashboard. The S3 SDK requires a non-empty region string on every client. The three S3-compatible providers have completely different semantics for this value:

Trap 3: ETag format — MinIO wraps in double quotes, which must be stripped. ETag values from the AWS S3 API are typically unquoted MD5 hashes. MinIO wraps them in double quotes in some responses: "d41d8cd98f00b204e9800998ecf8427e". If you store ETag values for conditional writes or integrity checks, strip the quotes:

// MinIO ETag stripping
const objects = (response.Contents ?? []).map(obj => ({
  key: obj.Key,
  etag: obj.ETag?.replace(/"/g, ''), // MinIO wraps ETag in quotes — strip them
  // S3 and R2 return unquoted ETags; stripping is safe and idempotent on those
}));

When to choose native vs S3-compatible

The choice is determined by the feature set you need, not preference:

Feature GCS (native) Azure Blob (native) R2 / MinIO / B2 (S3-compat)
Object versioning Yes — generation number per object Yes — blob snapshots + versioning R2: No. MinIO: Yes. B2: Yes (delete markers)
Resumable uploads Yes — resumable: true on file.save() for >5MB Yes — block blob multipart via SDK Yes — multipart upload via S3 API on all three
Temporary URLs Signed URLs via getSignedUrl() — requires iam.serviceAccountTokenCreator SAS via generateBlobSASQueryParameters() — requires account key or User Delegation Key All three: presigned URLs via getSignedUrl() from @aws-sdk/s3-request-presigner
Access tier management Storage class per object (STANDARD/NEARLINE/COLDLINE/ARCHIVE) Hot/Cool/Archive tier — Archive tier requires rehydration before read R2: no tiers. MinIO: lifecycle rules only. B2: no tiers
Signed URL from managed identity Requires iam.serviceAccountTokenCreator role on the service account itself Requires User Delegation Key API or storage account key — managed identity alone is not enough for key-based SAS All three: presigned URL generation uses S3 signing — works with any credential

If your MCP server needs object versioning, access tier management, or platform-native metadata features, use the native SDK. If your MCP server primarily needs list/get/put/delete/presigned-URL operations and you want a single code path that works across multiple storage backends, S3-compatible mode is the correct choice — with the three configuration traps handled explicitly.

Pattern 2 — Authentication model diversity: five models, five operational trade-offs

The five storage platforms use five structurally different authentication models. The differences are not cosmetic — they affect how credentials are provisioned, how they rotate, how they scope to specific buckets or containers, and what happens when they expire.

GCS: Application Default Credentials chain with bucket-level IAM

GCS authenticates via the Application Default Credentials (ADC) chain. The @google-cloud/storage SDK resolves credentials in this order: (1) the GOOGLE_APPLICATION_CREDENTIALS environment variable pointing to a service account JSON key file; (2) ~/.config/gcloud/application_default_credentials.json from the gcloud auth application-default login command; (3) the compute metadata server when running on Cloud Run, GKE, or GCE. The third option — attaching a service account to the Cloud Run service — is the recommended approach for deployed MCP servers. No key file to rotate or accidentally commit.

The critical IAM scoping rule: grant roles at the bucket level, not the project level. Project-level grants give the MCP server's service account access to every bucket in the project. Bucket-level grants restrict access to one bucket:

# Correct: bucket-level grant
gsutil iam ch \
  serviceAccount:mcp-server@PROJECT.iam.gserviceaccount.com:roles/storage.objectAdmin \
  gs://YOUR_BUCKET

# Wrong: project-level grant (too broad)
gcloud projects add-iam-policy-binding PROJECT \
  --member="serviceAccount:mcp-server@PROJECT.iam.gserviceaccount.com" \
  --role="roles/storage.objectAdmin"

One non-obvious GCS IAM requirement: generating signed URLs via file.getSignedUrl() requires the roles/iam.serviceAccountTokenCreator role granted to the service account on itself — not just the storage object roles. Without it, getSignedUrl() throws a permission denied error even when the service account can read and write objects. This is the most common signed URL permission mistake on GCS.

Azure Blob: DefaultAzureCredential chain with container-level RBAC

Azure Blob Storage authentication uses DefaultAzureCredential from @azure/identity, which tries multiple auth methods in sequence: EnvironmentCredential (service principal via AZURE_TENANT_ID + AZURE_CLIENT_ID + AZURE_CLIENT_SECRET), WorkloadIdentityCredential (AKS pod identity), ManagedIdentityCredential (App Service, Azure Functions, Azure Container Apps), AzureCliCredential (local dev via az login), and VisualStudioCodeCredential. The credential that resolves depends on what environment variables and what managed identity is attached to the compute resource — the same code runs in all environments, which is the appeal.

Grant RBAC roles at the container level, not the storage account level. Storage Blob Data Contributor at the storage account level gives access to every container in the account:

# Correct: container-level RBAC
az role assignment create \
  --assignee PRINCIPAL_ID \
  --role "Storage Blob Data Contributor" \
  --scope "/subscriptions/SUB/resourceGroups/RG/providers/Microsoft.Storage/storageAccounts/ACCOUNT/blobServices/default/containers/CONTAINER"

# Wrong: storage account level (too broad)
az role assignment create \
  --assignee PRINCIPAL_ID \
  --role "Storage Blob Data Contributor" \
  --scope "/subscriptions/SUB/resourceGroups/RG/providers/Microsoft.Storage/storageAccounts/ACCOUNT"

Azure Blob has a SAS URL generation asymmetry: generating SAS tokens from code requires either a StorageSharedKeyCredential (the storage account key, which grants full account access) or a User Delegation Key obtained via blobServiceClient.getUserDelegationKey(). A managed identity can call getUserDelegationKey(), but it requires the Storage Blob Delegator RBAC role. This is a separate role from Storage Blob Data Contributor — you need both if the MCP server needs to generate SAS URLs while running under managed identity.

R2: Cloudflare API token as S3 credentials

Cloudflare R2 uses API tokens created in the Cloudflare dashboard under R2 → Manage R2 API Tokens. These tokens are presented to the SDK as standard S3 credentials — accessKeyId and secretAccessKey — but they are Cloudflare-generated values, not AWS credentials. The token scopes map to S3 operations:

R2 token permission S3 equivalents Grant to MCP servers?
Object Read GetObject, HeadObject, ListObjectsV2 Yes — read-only tools
Object Read & Write All object operations including Put and Delete Yes — full read-write tools
Admin Read ListBuckets, GetBucketAcl, HeadBucket Required for health probe via HeadBucketCommand
Admin Read & Write Create/delete buckets, manage CORS, lifecycle No — not needed for tool operations

The Admin Read scope is required for HeadBucketCommand. If your health probe uses HeadBucket and the token lacks Admin Read, the probe returns 403 on every poll — AliveMCP sees the MCP server as always down, even when it's serving requests fine. Either include Admin Read in the token or use a different health probe that only requires Object Read.

R2 tokens can be scoped to specific buckets in the Cloudflare dashboard. A token scoped to my-mcp-bucket cannot access my-other-bucket even if the same account owns both — equivalent to S3 bucket-level IAM without the JSON policy syntax.

MinIO: service account keys — never root credentials

MinIO has two credential types that look similar but have completely different security properties. The root credentials (MINIO_ROOT_USER / MINIO_ROOT_PASSWORD) are set as environment variables at server startup and grant full admin access to the MinIO server — including creating and deleting buckets, managing users, and changing server configuration. Never use root credentials in MCP server code.

The correct approach is a service account created via the MinIO client (mc) with a bucket-scoped policy:

# Create a service account for the MCP server
mc admin user create myminio mcp-server-user STRONG_SECRET_HERE

# Create a bucket-scoped policy
mc admin policy create myminio mcp-server-policy '{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "s3:GetObject",
      "s3:PutObject",
      "s3:DeleteObject",
      "s3:ListBucket",
      "s3:HeadObject",
      "s3:HeadBucket"
    ],
    "Resource": [
      "arn:aws:s3:::YOUR_BUCKET",
      "arn:aws:s3:::YOUR_BUCKET/*"
    ]
  }]
}'

# Attach the policy to the service account
mc admin policy attach myminio mcp-server-policy --user mcp-server-user

MinIO also supports STS-based temporary credentials for agents that need short-lived access — the AssumeRole API works against MinIO if an identity provider is configured. For most MCP server use cases, a dedicated service account with a bucket-scoped policy is sufficient and simpler to operate.

B2: application keys scoped to specific bucket — never the master key

Backblaze B2 distinguishes the master key (full account access, generated at account creation, visible on the dashboard) from application keys (scoped access, created on demand, limited to specified capabilities and optionally restricted to one bucket). Never use the master key in MCP server code — it grants access to every bucket in the account and cannot be revoked without generating a new master key.

Application keys are created in the B2 dashboard under App Keys → Add a New Application Key. The critical configuration choices during creation:

B2 application key capabilities map to S3 permissions as follows:

B2 capability S3 equivalent Grant to MCP servers?
readFiles s3:GetObject Yes
writeFiles s3:PutObject Yes
deleteFiles s3:DeleteObject Yes — with confirm guard
listFiles s3:ListBucket Yes
listBuckets s3:ListAllMyBuckets No — lock bucket in server config
readBucketEncryption s3:GetBucketEncryption Optional — for HeadBucket health probe

Auth model comparison: operational trade-offs

Platform Credential rotation impact Keyless option for deployed servers? Scope granularity
GCS Service account key file rotation — update env var + restart. Compute metadata: no rotation needed. Yes — attach service account to Cloud Run/GKE, no key file IAM roles at bucket level or specific object prefix
Azure Blob Service principal secret rotation — update AZURE_CLIENT_SECRET + restart. Managed identity: no rotation needed. Yes — managed identity on App Service/ACA, no secrets RBAC at container level
R2 Delete token + create new one — update R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY + restart No — always requires R2 API token credentials Per-bucket token scope in dashboard
MinIO Delete service account + create new one — or use STS for short-lived tokens No — always requires access/secret key pair IAM-style JSON policy with bucket-level or prefix-level statements
B2 Delete application key + create new one — update B2_APPLICATION_KEY_ID + B2_APPLICATION_KEY + restart No — always requires application key pair Per-bucket key scope in dashboard

GCS and Azure Blob have keyless deployment options — managed identity approaches where the compute runtime provides credentials without any secret configuration. R2, MinIO, and B2 always require explicit credential pairs in the environment. For teams already running on GCP or Azure, the managed identity path eliminates the credential rotation problem entirely and is the recommended choice. For teams running on other infrastructure, R2/MinIO/B2 all require an operational rotation process.

Pattern 3 — Two-layer health probe design: why one probe is never enough

A health probe for a storage MCP server has two jobs: verify that the storage backend is reachable, and verify that the credentials are valid and have the permissions your tools require. The most common mistake is a probe that does one but not the other — and the one most often skipped is credential validation.

MinIO: the two-probe problem

MinIO exposes a dedicated process health endpoint: GET /minio/health/live. This endpoint returns HTTP 200 when the MinIO process is alive. It does not verify credentials. It does not verify that the bucket exists. It does not verify that the service account has permission to access the bucket. A MinIO server with fully revoked credentials still returns 200 from /minio/health/live.

This creates a specific monitoring failure when this endpoint is registered with AliveMCP: the MCP server reports as healthy, tool calls start returning 403, and there is no alert.

The correct MinIO health strategy uses two probes at different layers:

import { HeadBucketCommand } from '@aws-sdk/client-s3';
import axios from 'axios';
import { minio, BUCKET_NAME, MINIO_ENDPOINT } from './minio-client.js';

app.get('/health/minio', async (req, res) => {
  // Layer 1: process health — is MinIO alive at all?
  try {
    const liveness = await axios.get(`${MINIO_ENDPOINT}/minio/health/live`, {
      timeout: 3000
    });
    if (liveness.status !== 200) {
      return res.status(503).json({ status: 'unhealthy', reason: 'minio_process_down' });
    }
  } catch {
    return res.status(503).json({ status: 'unhealthy', reason: 'minio_unreachable' });
  }

  // Layer 2: credential + bucket health — can this MCP server actually access its data?
  try {
    await minio.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
    return res.json({ status: 'healthy', bucket: BUCKET_NAME });
  } catch (err: any) {
    const reason = err.name === 'NoSuchBucket'
      ? 'bucket_not_found'
      : err.$metadata?.httpStatusCode === 403
        ? 'credentials_invalid_or_insufficient'
        : 'bucket_access_failed';
    return res.status(503).json({ status: 'unhealthy', reason });
  }
});

Register the /health/minio endpoint — not /minio/health/live — with AliveMCP. The process liveness check is a prerequisite, but credential and bucket validation is what catches the failure modes that actually affect tool calls.

Azure Blob: the Archive tier trap

Azure Blob's containerClient.getProperties() is the correct health probe — it verifies credential validity and container access. The problem is that a healthy getProperties()` response does not guarantee that all blob reads will succeed.

Azure Blob Storage has three access tiers: Hot, Cool, and Archive. Blobs in Archive tier are stored offline — they cannot be read directly. Any attempt to download an Archive-tier blob returns HTTP 409 with error code BlobArchived:

// Azure Blob Archive tier — this error is not a credential failure
try {
  const downloadResponse = await blobClient.download(0);
} catch (err: any) {
  if (err.statusCode === 409 && err.code === 'BlobArchived') {
    // The blob exists and the credentials are valid
    // But the blob is in Archive tier and cannot be read
    // Must call blobClient.setAccessTier('Hot') to rehydrate
    // Rehydration takes 1–15 hours depending on priority
    throw new McpError(
      ErrorCode.InvalidParams,
      `Blob "${name}" is in Archive tier. Rehydrate it first: call rehydrate_blob, ` +
      `then retry after 1-15 hours.`
    );
  }
}

A health probe that only calls containerClient.getProperties() will pass even when every blob in the container is in Archive tier and unreadable by your tools. If your MCP server reads blobs that could be archived, extend the health probe to check a canary blob:

app.get('/health/azure-blob', async (req, res) => {
  try {
    // Check container accessibility
    await containerClient.getProperties();

    // Optionally check a canary blob's tier
    const canaryClient = containerClient.getBlobClient('_health-canary');
    let tierWarning: string | null = null;
    try {
      const props = await canaryClient.getProperties();
      if (props.accessTier === 'Archive') {
        tierWarning = 'canary_blob_in_archive_tier';
      }
    } catch {
      // Canary blob doesn't exist — that's fine, container is accessible
    }

    return res.json({
      status: 'healthy',
      container: CONTAINER_NAME,
      warning: tierWarning
    });
  } catch (err: any) {
    const reason = err.statusCode === 403 ? 'credentials_invalid'
      : err.statusCode === 404 ? 'container_not_found'
      : 'azure_blob_unreachable';
    return res.status(503).json({ status: 'unhealthy', reason });
  }
});

GCS: bucket.getMetadata() catches IAM errors

GCS health probes should use bucket.getMetadata(). This call requires storage.buckets.get permission, which is included in all three object roles (objectViewer, objectCreator, objectAdmin) alongside legacyBucketReader. The call returns bucket metadata including location and storage class, and throws on all the failure modes that affect tool calls:

  • HTTP 403: credentials are invalid, expired, or missing the required IAM roles
  • HTTP 404: bucket does not exist or was deleted
  • Network error: GCS is unreachable
app.get('/health/gcs', async (req, res) => {
  try {
    const [metadata] = await bucket.getMetadata();
    return res.json({
      status: 'healthy',
      bucket: BUCKET_NAME,
      location: metadata.location,
      storage_class: metadata.storageClass
    });
  } catch (err: any) {
    const reason = err.code === 403 ? 'iam_permission_denied'
      : err.code === 404 ? 'bucket_not_found'
      : 'gcs_unreachable';
    return res.status(503).json({ status: 'unhealthy', reason, code: err.code });
  }
});

R2 and B2: HeadBucketCommand — but watch for scope gaps

Both R2 and B2 use HeadBucketCommand as the health probe via the S3-compatible API. The response semantics differ slightly:

  • R2: 403 means the token was revoked or the token lacks bucket access. 404 means the bucket was deleted from the account. Note that the R2 HeadBucketCommand response does not include the x-amz-bucket-region header that AWS S3 returns — that is expected and is not an error.
  • B2: 403 means the application key was deleted or its permissions were changed. 301 or 403 from a wrong region is the most common misconfiguration — double-check that the client's region matches the bucket's actual region code from the B2 dashboard.
import { HeadBucketCommand, S3ServiceException } from '@aws-sdk/client-s3';

app.get('/health/r2', async (req, res) => {
  try {
    await r2.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
    return res.json({ status: 'healthy', bucket: BUCKET_NAME });
  } catch (err: any) {
    const code = err.$metadata?.httpStatusCode;
    const reason = code === 403 ? 'token_revoked_or_missing_scope'
      : code === 404 ? 'bucket_deleted'
      : 'r2_unreachable';
    return res.status(503).json({ status: 'unhealthy', reason, http_code: code });
  }
});

app.get('/health/b2', async (req, res) => {
  try {
    await b2.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
    return res.json({ status: 'healthy', bucket: BUCKET_NAME, region: B2_REGION });
  } catch (err: any) {
    const code = err.$metadata?.httpStatusCode;
    const reason = code === 403 ? 'application_key_revoked_or_wrong_region'
      : code === 301 ? 'wrong_region_check_b2_dashboard'
      : code === 404 ? 'bucket_not_found'
      : 'b2_unreachable';
    return res.status(503).json({ status: 'unhealthy', reason, http_code: code });
  }
});

Health probe comparison

Platform Correct probe Wrong probe What the wrong probe misses
GCS bucket.getMetadata() HTTP GET to GCS hostname IAM permission revocation; wrong bucket name
Azure Blob containerClient.getProperties() HTTP GET to Azure storage hostname Credential expiry; container deletion; Archive tier blobs
R2 HeadBucketCommand (requires Admin Read token scope) Ping R2 endpoint Token revocation; missing bucket-level scope
MinIO /minio/health/live + HeadBucketCommand (both layers) /minio/health/live alone Revoked credentials; bucket deleted; policy changes
B2 HeadBucketCommand Ping B2 endpoint Application key revocation; wrong region configuration

Composable storage MCP server: handling multiple backends

If you are building a single MCP server that integrates multiple storage backends — a common pattern for agent workflows that read from one store and write to another — the three patterns above suggest a consistent initialization and health check structure.

Initialize all storage clients at startup and validate each one before registering tools. The startup validation should fail fast if any backend is unreachable — a broken storage credential discovered at tool call time is harder to diagnose than one caught at server startup:

import { Storage } from '@google-cloud/storage';
import { BlobServiceClient } from '@azure/storage-blob';
import { DefaultAzureCredential } from '@azure/identity';
import { S3Client, HeadBucketCommand } from '@aws-sdk/client-s3';

// Initialize all clients at module level — singleton pattern
const gcs = new Storage({ projectId: process.env.GCP_PROJECT_ID });
const gcsBucket = gcs.bucket(process.env.GCS_BUCKET_NAME!);

const azureCredential = new DefaultAzureCredential();
const azureBlobService = new BlobServiceClient(
  `https://${process.env.AZURE_STORAGE_ACCOUNT}.blob.core.windows.net`,
  azureCredential
);
const azureContainer = azureBlobService.getContainerClient(process.env.AZURE_CONTAINER_NAME!);

const r2 = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!
  }
});

// Validate all backends at startup
async function validateAllBackends() {
  const results = await Promise.allSettled([
    gcsBucket.getMetadata().then(() => ({ backend: 'gcs', status: 'healthy' })),
    azureContainer.getProperties().then(() => ({ backend: 'azure', status: 'healthy' })),
    r2.send(new HeadBucketCommand({ Bucket: process.env.R2_BUCKET_NAME! }))
      .then(() => ({ backend: 'r2', status: 'healthy' }))
  ]);

  for (const result of results) {
    if (result.status === 'rejected') {
      throw new Error(`Storage backend validation failed: ${result.reason}`);
    }
    console.log(`[startup] ${result.value.backend}: ${result.value.status}`);
  }
}

// Aggregated health endpoint for AliveMCP to poll
app.get('/health', async (req, res) => {
  const checks: Record = {};

  await Promise.allSettled([
    gcsBucket.getMetadata()
      .then(() => { checks.gcs = 'healthy'; })
      .catch(() => { checks.gcs = 'unhealthy'; }),
    azureContainer.getProperties()
      .then(() => { checks.azure = 'healthy'; })
      .catch(() => { checks.azure = 'unhealthy'; }),
    r2.send(new HeadBucketCommand({ Bucket: process.env.R2_BUCKET_NAME! }))
      .then(() => { checks.r2 = 'healthy'; })
      .catch(() => { checks.r2 = 'unhealthy'; })
  ]);

  const allHealthy = Object.values(checks).every(v => v === 'healthy');
  return res.status(allHealthy ? 200 : 503).json({
    status: allHealthy ? 'healthy' : 'degraded',
    checks
  });
});

Register the /health endpoint with AliveMCP. The aggregated probe checks all three backends in parallel — a 60-second poll cycle catches a credential failure on any backend within one minute, before an agent calls a storage tool and receives a 403 with no explanation.

The delete_object confirm guard

Across all five platforms, deletes are irreversible — object versioning in GCS and B2 softens this (you can restore a previous version), but for platforms without versioning, a deleted object is gone. All five storage integrations should implement the confirm guard on delete operations:

// Same pattern across all five platforms
server.tool(
  'delete_object',
  {
    key: z.string().min(1).max(1024),
    confirm: z.literal(true)
      .describe('Must be true. Prevents accidental deletes from ambiguous LLM instructions.')
  },
  async ({ key }) => {
    validateKey(key);
    // Platform-specific delete call here
    return { content: [{ type: 'text', text: JSON.stringify({ deleted: key }) }] };
  }
);

GCS adds an optional generation parameter to the confirm guard. If specified, GCS deletes only the object at that specific generation number — preventing a race condition where your tool reads an object, another process updates it, and your delete removes the newer version instead of the one you inspected. For MCP tools operating on shared documents or versioned outputs, passing generation is a meaningful additional guard.

Azure Blob adds a delete_snapshots parameter — if a blob has snapshots and you delete the base blob without specifying deleteSnapshots: 'include', the delete returns 409 Conflict. For most MCP tools, the correct behavior is to require explicit confirmation that snapshots should also be deleted before proceeding.

Summary: the three patterns as a checklist

For every cloud storage MCP server integration:

1. SDK choice: Use the native SDK for GCS and Azure Blob — they have IAM, access tier, and signing features that have no S3-compatible equivalent. For R2, MinIO, and B2, use @aws-sdk/client-s3 with the correct provider-specific configuration: region: 'auto' for R2, forcePathStyle: true for MinIO (and any non-empty region string), and the bucket's actual region code from the B2 dashboard for B2. Never assume S3-compatible means configuration-identical — the three traps (forcePathStyle, region semantics, ETag format) are all silent failures.

2. Authentication model: Use the keyless credential path when available — managed identity on Cloud Run or AKS for GCS, managed identity on App Service or ACA for Azure Blob. For R2, MinIO, and B2, always use scoped credentials (R2 API token scoped to the bucket, MinIO service account with a bucket-level IAM policy, B2 application key restricted to the bucket). Never use root credentials (MinIO) or master keys (B2) in MCP server code. Grant IAM roles and RBAC assignments at the bucket or container level, not the project or storage account level.

3. Health probe depth: The correct probe for each platform exercises credential validation, not just connectivity. GCS: bucket.getMetadata(). Azure Blob: containerClient.getProperties() — and be aware that Archive-tier blobs produce 409 errors on read that a container-level probe will not catch. R2: HeadBucketCommand with an API token that includes Admin Read scope. MinIO: two layers — /minio/health/live for process health, then HeadBucketCommand for credential and bucket health — register only the second URL with AliveMCP. B2: HeadBucketCommand — a 301 response means the client region does not match the bucket's actual region.

All five platforms need external monitoring with AliveMCP because their internal availability indicators — GCS's service status page, Azure's Service Health dashboard, Cloudflare's status page — report on platform availability, not on whether your specific credentials, bucket configuration, and IAM policy are still valid. An expired GCS service account key passes Google's own status page. A revoked B2 application key does not affect Backblaze's uptime metrics. A MinIO process running with fully revoked service account credentials returns 200 from its own health endpoint. None of these failures appear in the platform's own health reporting — they appear as 403 errors after your agent has already called the tool.

Further reading

Know when your storage backend fails before your agents do

AliveMCP polls your /health endpoint every 60 seconds and alerts you the moment a GCS service account key expires, a MinIO service account is revoked, or a B2 application key is deleted — before your MCP tools start returning 403 with no explanation.

Start monitoring free