Guide · MCP Cloud Storage Integration
MCP Server MinIO — S3-compatible self-hosted storage, /minio/health/live probe, and credential validation
MinIO is the leading self-hosted S3-compatible object store — running on Docker, Kubernetes, or bare metal with the same S3 API your tools already know. This guide covers connecting MCP tools to MinIO using @aws-sdk/client-s3 with forcePathStyle: true (the critical difference from hosted S3), understanding the two-layer health probe design (/minio/health/live for process health, HeadBucketCommand for credential health), lifecycle rules for auto-expiring temporary agent outputs, and a Docker Compose setup for local development.
TL;DR
Use @aws-sdk/client-s3 with the MinIO endpoint (e.g., http://localhost:9000 or your MinIO domain), your MinIO access/secret key, and forcePathStyle: true — this is the single most common misconfiguration. Without it, the SDK constructs virtual-hosted-style URLs (https://bucket.your-minio.com) that fail unless MinIO is configured with a wildcard DNS entry. Health probe design: GET /minio/health/live returns 200 when the MinIO process is alive, but does NOT verify credentials. Always follow it with a HeadBucketCommand to verify the access key and bucket. Register only the HeadBucketCommand URL with AliveMCP so you catch real access failures, not just process liveness.
SDK setup and MinIO authentication
import { S3Client } from '@aws-sdk/client-s3';
const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT ?? 'http://localhost:9000';
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME!;
if (!BUCKET_NAME) throw new Error('MINIO_BUCKET_NAME is required');
const minio = new S3Client({
region: 'us-east-1', // MinIO ignores region but SDK requires a non-empty value
endpoint: MINIO_ENDPOINT,
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY!,
secretAccessKey: process.env.MINIO_SECRET_KEY!,
},
// CRITICAL: MinIO uses path-style URLs by default
// Without this, the SDK sends: https://bucket.your-minio-domain.com/key
// MinIO expects: http://your-minio-domain.com:9000/bucket/key
forcePathStyle: true,
maxAttempts: 3
});
export { minio, BUCKET_NAME, MINIO_ENDPOINT };
MinIO credential types and when to use each:
| Credential type | Created via | Use for |
|---|---|---|
| Root credentials | MINIO_ROOT_USER / MINIO_ROOT_PASSWORD env vars at startup |
MinIO admin only — never use for MCP server tools |
| Service account access keys | mc admin user create myminio mcpserver SECRETKEY then mc admin policy attach |
Recommended for MCP servers — scoped to specific bucket policies |
| Temporary STS credentials | MinIO STS API (AssumeRole) | Short-lived credentials for agents; requires identity provider setup |
Create a dedicated service account for the MCP server using the MinIO client (mc): mc admin user create myminio mcp-server-user STRONG_SECRET_HERE. Then 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"],"Resource":["arn:aws:s3:::YOUR_BUCKET","arn:aws:s3:::YOUR_BUCKET/*"]}]}'. Attach: mc admin policy attach myminio mcp-server-policy --user mcp-server-user.
Core MinIO tool patterns
All S3 operations work identically against MinIO — the only code difference from S3 tools is the minio client with forcePathStyle: true.
import {
GetObjectCommand,
PutObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
HeadObjectCommand
} from '@aws-sdk/client-s3';
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { minio, BUCKET_NAME } from './minio-client.js';
function validateKey(key: string): void {
if (!key || key.includes('..') || key.startsWith('/') || /[\0\r\n]/.test(key)) {
throw new McpError(ErrorCode.InvalidParams, `Invalid 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 minio.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?.replace(/"/g, '') // MinIO wraps ETag in quotes — strip them
})),
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 minio.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
if (!response.Body) {
throw new McpError(ErrorCode.InternalError, 'Empty response from MinIO');
}
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?.replace(/"/g, '')
})
}]
};
} 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 minio.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?.replace(/"/g, ''), bytes: body.length })
}]
};
}
);
// ---- delete_object ----
server.tool(
'delete_object',
{
key: z.string().min(1).max(1024),
confirm: z.literal(true)
},
async ({ key }) => {
validateKey(key);
await minio.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }));
return {
content: [{ type: 'text', text: JSON.stringify({ deleted: key }) }]
};
}
);
One quirk to be aware of: MinIO wraps ETag values in double quotes in some responses ("abc123" instead of abc123). This is technically RFC-compliant but can surprise consumers expecting a bare hex string. Strip the quotes with .replace(/"/g, '') before returning ETags in tool responses.
Two-layer health probe: process health vs credential health
MinIO provides a built-in health endpoint at /minio/health/live that returns HTTP 200 when the MinIO process is running. However, this endpoint does not check credentials, bucket existence, or policy access — a MinIO process can be alive while your MCP server's credentials are revoked or the bucket is missing. A robust health probe checks both layers.
import express from 'express';
import { HeadBucketCommand } from '@aws-sdk/client-s3';
import { minio, BUCKET_NAME, MINIO_ENDPOINT } from './minio-client.js';
import https from 'https';
import http from 'http';
const app = express();
// Layer 1: MinIO process liveness — no credentials needed
async function checkMinioLiveness(): Promise<{ alive: boolean; latency_ms: number }> {
return new Promise((resolve) => {
const start = Date.now();
const url = new URL(`${MINIO_ENDPOINT}/minio/health/live`);
const requester = url.protocol === 'https:' ? https : http;
const req = requester.get(url.toString(), (res) => {
resolve({ alive: res.statusCode === 200, latency_ms: Date.now() - start });
res.resume();
});
req.on('error', () => resolve({ alive: false, latency_ms: Date.now() - start }));
req.setTimeout(5000, () => {
req.destroy();
resolve({ alive: false, latency_ms: Date.now() - start });
});
});
}
// Layer 2: Credential and bucket access validation
async function checkBucketAccess(): Promise<{ accessible: boolean; latency_ms: number; error?: string }> {
const start = Date.now();
try {
await minio.send(new HeadBucketCommand({ Bucket: BUCKET_NAME }));
return { accessible: true, latency_ms: Date.now() - start };
} catch (err: any) {
const statusCode = err.$metadata?.httpStatusCode;
return {
accessible: false,
latency_ms: Date.now() - start,
error: `HTTP ${statusCode}: ${err.name}`
};
}
}
app.get('/health/minio', async (_req, res) => {
const [liveness, access] = await Promise.all([
checkMinioLiveness(),
checkBucketAccess()
]);
const healthy = liveness.alive && access.accessible;
res.status(healthy ? 200 : 503).json({
status: healthy ? 'ok' : 'error',
checks: {
process_alive: {
status: liveness.alive ? 'ok' : 'error',
latency_ms: liveness.latency_ms,
note: '/minio/health/live — MinIO process is running'
},
bucket_access: {
status: access.accessible ? 'ok' : 'error',
latency_ms: access.latency_ms,
error: access.error,
note: 'HeadBucket — credentials valid and bucket accessible'
}
},
bucket: BUCKET_NAME,
endpoint: MINIO_ENDPOINT
});
});
app.listen(3001);
| Failure mode | /minio/health/live | HeadBucket | Overall health |
|---|---|---|---|
| MinIO process crashed | Connection refused | Connection refused | 503 — both checks fail |
| Access key revoked | 200 (process alive) | 403 Forbidden | 503 — process OK, access fails |
| Bucket deleted | 200 (process alive) | 404 Not Found | 503 — process OK, bucket missing |
| Policy removed from user | 200 (process alive) | 403 Forbidden | 503 — process OK, policy denied |
| All systems nominal | 200 | 200 | 200 — healthy |
Register the /health/minio endpoint (not /minio/health/live directly) with AliveMCP. The MinIO process health check alone will report false positives — a running-but-inaccessible MinIO is not healthy for your MCP server's purposes.
Lifecycle rules for temporary agent output cleanup
MCP servers frequently write intermediate agent outputs (analysis results, tool outputs, draft files) that should auto-expire. MinIO supports S3-compatible lifecycle rules, letting you set TTLs without requiring explicit delete calls from the MCP server.
import { PutBucketLifecycleConfigurationCommand } from '@aws-sdk/client-s3';
// Set lifecycle rule at server startup or via a one-time setup script
async function setupLifecycleRules(): Promise<void> {
await minio.send(new PutBucketLifecycleConfigurationCommand({
Bucket: BUCKET_NAME,
LifecycleConfiguration: {
Rules: [
{
ID: 'expire-tmp-after-24h',
Status: 'Enabled',
Filter: { Prefix: 'tmp/' },
Expiration: { Days: 1 }
},
{
ID: 'expire-drafts-after-7d',
Status: 'Enabled',
Filter: { Prefix: 'drafts/' },
Expiration: { Days: 7 }
}
]
}
}));
}
// In your put_object tool, use prefixes to control TTL:
// key: 'tmp/analysis-2026-07-10-session-42.json' → expires after 1 day
// key: 'drafts/report-v1.md' → expires after 7 days
// key: 'outputs/final-report.pdf' → no expiry rule (permanent)
Lifecycle rule evaluation in MinIO runs on a background scan cycle (default: 1 hour). Objects are not deleted exactly at midnight — expiry is best-effort within the scan window. For hard-TTL requirements (e.g., security-sensitive temp files), combine lifecycle rules with explicit delete_object calls at the end of each agent session.
Docker Compose setup for local development
# docker-compose.yml
services:
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
ports:
- "9000:9000" # S3 API — your MCP server connects here
- "9001:9001" # MinIO Console UI — browse buckets in browser
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 3
# Optional: create bucket and user at startup
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set local http://minio:9000 minioadmin minioadmin123 &&
mc mb local/mcp-server-bucket --ignore-existing &&
mc admin user create local mcp-server STRONGSECRET123 &&
mc admin policy create local mcp-policy '{
\"Version\": \"2012-10-17\",
\"Statement\": [{
\"Effect\": \"Allow\",
\"Action\": [\"s3:*\"],
\"Resource\": [
\"arn:aws:s3:::mcp-server-bucket\",
\"arn:aws:s3:::mcp-server-bucket/*\"
]
}]
}' &&
mc admin policy attach local mcp-policy --user mcp-server
"
volumes:
minio_data:
Environment variables for your MCP server in the same Docker Compose network:
# .env.local
MINIO_ENDPOINT=http://minio:9000 # Use service name in Docker network
MINIO_BUCKET_NAME=mcp-server-bucket
MINIO_ACCESS_KEY=mcp-server
MINIO_SECRET_KEY=STRONGSECRET123
Presigned URLs with MinIO
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
server.tool(
'generate_download_url',
{
key: z.string().min(1).max(1024),
expires_in_seconds: z.number().int().min(60).max(604800).default(3600)
},
async ({ key, expires_in_seconds }) => {
validateKey(key);
// Verify the object exists
try {
await minio.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(minio, command, { expiresIn: expires_in_seconds });
// IMPORTANT: if MinIO is behind a reverse proxy with a public domain,
// the signed URL will contain the public domain (correct for callers).
// If MinIO_ENDPOINT is an internal hostname (http://minio:9000), the
// signed URL will contain that internal hostname — callers outside Docker
// cannot reach it. Replace the host in the URL for external callers:
const publicUrl = process.env.MINIO_PUBLIC_URL
? url.replace(MINIO_ENDPOINT, process.env.MINIO_PUBLIC_URL)
: url;
const expiresAt = new Date(Date.now() + expires_in_seconds * 1000);
return {
content: [{
type: 'text',
text: JSON.stringify({
url: publicUrl,
key,
expires_at: expiresAt.toISOString(),
expires_in_seconds
})
}]
};
}
);
The internal hostname replacement is a common gotcha with MinIO in Docker: the SDK signs the URL using the endpoint configured in the S3 client. If that endpoint is an internal Docker service name (http://minio:9000), the signed URL is unreachable from outside the Docker network. Set MINIO_PUBLIC_URL=https://files.your-domain.com and do the string replacement, or configure the MinIO client with the public URL directly (then ensure the MinIO server's TLS is properly configured).
Frequently asked questions
Should I use the MinIO JavaScript SDK or the AWS S3 SDK?
For MCP server tools, the AWS S3 SDK (@aws-sdk/client-s3) is the better choice unless you specifically need MinIO-only features. Using the S3 SDK means your tool code is portable — you can switch between MinIO, S3, R2, B2, or GCS's XML API by swapping the client configuration without changing tool implementation code. The MinIO JS SDK (minio npm package) is primarily useful for MinIO-specific administration operations: setting ILM (Information Lifecycle Management) policies via the MinIO Admin API, configuring server-side replication, or using MinIO's batch operations. For read/write/delete MCP tool operations, stick with the S3 SDK.
How do I run MinIO in distributed mode for high availability?
MinIO distributed mode (erasure coding across multiple drives/nodes) is configured at startup by passing multiple endpoints to the server command: minio server http://minio-{1...4}/data. The cluster needs a minimum of 4 nodes. Each node's data directory is striped with erasure coding, so the cluster tolerates losing up to N/2 drives without data loss. For MCP servers, the connection endpoint is a load balancer (NGINX, Cloudflare, or an AWS ALB) in front of the MinIO nodes — the SDK connects to the load balancer URL and MinIO handles internal routing. The /minio/health/live endpoint is per-node; if you're running behind a load balancer, configure the load balancer health check to hit each node's /minio/health/live endpoint. The cluster is healthy as long as a majority of nodes are up.
How do I enable server-side encryption (SSE) in MinIO?
MinIO supports three SSE modes: SSE-S3 (MinIO-managed keys using AES-256), SSE-KMS (keys from KMS — HashiCorp Vault, Thales, or AWS KMS), and SSE-C (caller-provided key per request). For MCP servers, SSE-S3 is the simplest: enable it in MinIO with mc encrypt set sse-s3 myminio/BUCKET. Once enabled, all PUT operations to that bucket are automatically encrypted at rest — no changes to your MCP tool code needed. For SSE-C (caller-controlled key), you must pass three additional headers on every PutObject and GetObject call: x-amz-server-side-encryption-customer-algorithm: AES256, x-amz-server-side-encryption-customer-key (base64-encoded 32-byte key), and x-amz-server-side-encryption-customer-key-MD5. The AWS SDK supports SSE-C via the SSECustomerAlgorithm, SSECustomerKey, and SSECustomerKeyMD5 parameters on each command.
What's the difference between /minio/health/live and /minio/health/ready?
MinIO exposes two health check endpoints. /minio/health/live returns 200 as long as the MinIO process is running — it answers immediately without checking any subsystems. This is a liveness probe: if it fails, Kubernetes restarts the container. /minio/health/ready additionally checks whether MinIO has finished startup and is ready to serve requests — during startup, MinIO scans existing data for erasure coding metadata, which can take time. /minio/health/ready returns 503 during this window. For AliveMCP monitoring, neither endpoint alone is sufficient for detecting credential or bucket problems — both are process-level checks. Use the combined probe from this guide (/minio/health/live + HeadBucketCommand) to get a complete picture of your MinIO integration's health.
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 Backblaze B2 — S3-compatible API tools and /health/b2 for uptime monitoring
- MCP Server Health Check — designing endpoints for uptime monitors
- MCP Tools for Cloud Storage — SDK choice, auth models, and health probe patterns across GCS, Azure, R2, MinIO, and B2