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

Monitor your MinIO integration beyond just process liveness

AliveMCP polls your combined /health/minio endpoint every 60 seconds — detecting credential revocations and missing buckets that /minio/health/live alone cannot catch.

Start monitoring free