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:
- R2: Set
region: 'auto'. Cloudflare does not use AWS regions;'auto'suppresses region-mismatch errors from the SDK validation layer. Any other value produces a region-mismatch warning but still works —'auto'is the explicit intent signal. - MinIO: Set any non-empty string — MinIO ignores region entirely. The conventional value is
'us-east-1'because it suppresses the SDK's default-region warning. - B2: Set to the bucket's actual region code from the B2 dashboard Bucket Settings tab — values like
'us-west-004'or'eu-central-003'. Wrong region → the SDK sends requests to the wrong endpoint, which returns HTTP 301 or 403. This is the single most common B2 configuration mistake. The region is not a generic AWS region name — it is a B2-specific location code visible only in the dashboard.
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:
- Restrict to Bucket: set to your specific bucket name. A key without bucket restriction has access to all buckets in the account.
- Type of Access: set to Read and Write for full MCP server access, or Read Only for read-only tools.
- Copy the
applicationKeyimmediately: it is shown only once at creation. ThekeyID(which maps toaccessKeyId) is always retrievable from the dashboard.
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
HeadBucketCommandresponse does not include thex-amz-bucket-regionheader 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
regionmatches 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
- MCP Server GCP Cloud Storage — list, download, upload, signed URLs, and /health/gcs
- MCP Server Azure Blob Storage — list blobs, download, upload, SAS URLs, and /health/azure-blob
- MCP Server Cloudflare R2 — S3-compatible storage tools, presigned URLs, and /health/r2
- MCP Server MinIO — S3-compatible self-hosted storage, /minio/health/live probe, and credential validation
- MCP Server Backblaze B2 — S3-compatible API tools, application keys, and /health/b2
- MCP Server AWS S3 — IAM credential chain, presigned URLs, and HeadBucket health probes
- Building MCP Tools for Data Infrastructure — the three contracts for PostgreSQL, MySQL, S3, Kafka, and DSPy
- MCP Server Health Check — probe depth and alert tier design
- MCP Server Error Handling — structured errors and retry patterns
- JSON-RPC Health Checks vs HTTP Probes — what each layer catches