Skip to main content
Subscribe
Front Page / AI Tools / Deep Dive

Build a MinIO Object Storage MCP Server for Agentic Document Retrieval in 2026

AI agents need access to documents, images, and data files stored in object storage—but exposing raw S3 credentials creates a catastrophic blast radius. This FastMCP MinIO server provides scoped, metadata-filtered access with presigned URLs that expire automatically, giving agents safe file access without long-lived credentials.

Deepak Bagada

Deepak Bagada

Founder & Editor-in-Chief

Aug 22, 2026 Published
|
Aug 22, 2026 Updated
|
6 Minutes Reading Time
Core Takeaways for Founders & Builders
  • Presigned URLs with 5-minute TTL give agents file access without ever receiving MinIO credentials—zero credential exposure risk
  • Metadata search with tag filtering lets agents discover relevant documents without listing entire buckets
  • Bucket-level access control restricts agents to specific paths, preventing lateral movement across storage

AI agents that need to read documents, images, or datasets from object storage face a binary choice: store S3 credentials in the agent's context (catastrophic if compromised) or deny file access entirely (useless for data-heavy workflows). Neither option works in production.

This FastMCP MinIO server generates presigned URLs on demand. When an agent needs to read a file, it calls get_file_url with the bucket and key. The server generates a time-limited presigned URL (default 5 minutes) that grants read-only access to that specific object. The agent downloads the file via the presigned URL without ever receiving MinIO credentials. Every access is logged, every URL expires, and credential exposure is zero.

Architecture

Claude / Cursor Agent
        │
        ▼ MCP Protocol
┌───────────────────┐
│  MinIO MCP Server │
│  (FastMCP + S3)   │
├───────────────────┤
│ • Presigned URLs   │
│ • Metadata Search  │
│ • Scoped Access    │
│ • Audit Logging    │
└────────┬──────────┘
         │ S3 API (HTTPS)
         ▼
┌───────────────────┐
│  MinIO / S3       │
│  • Buckets        │
│  • Objects        │
│  • Versioning     │
└───────────────────┘

File Structure

minio-mcp-server/
├── src/
│   ├── server.ts          # FastMCP server with MinIO tools
│   ├── minio-client.ts    # MinIO S3 client wrapper
│   ├── presigner.ts       # Presigned URL generation
│   └── metadata.ts        # Object metadata indexing
├── config.yaml
├── package.json
└── tsconfig.json

FastMCP Server

// src/server.ts
import { FastMCP } from "fastmcp";
import { z } from "zod";
import { MinIOClientWrapper } from "./minio-client.js";
import { Presigner } from "./presigner.js";
import { MetadataIndex } from "./metadata.js";

const minioEndpoint = process.env.MINIO_ENDPOINT || "localhost:9000";
const minioAccessKey = process.env.MINIO_ACCESS_KEY || "";
const minioSecretKey = process.env.MINIO_SECRET_KEY || "";
const defaultTtl = parseInt(process.env.PRESIGN_TTL || "300"); // 5 min

const minio = new MinIOClientWrapper(minioEndpoint, minioAccessKey, minioSecretKey);
const presigner = new Presigner(minio, defaultTtl);
const metadata = new MetadataIndex(minio);

const server = new FastMCP({
  name: "minio-documents",
  version: "1.0.0",
});

// Tool: Get presigned download URL for a file
server.tool(
  "get_file_url",
  "Get a time-limited presigned URL to download a file from MinIO",
  {
    bucket: z.string().describe("Bucket name"),
    key: z.string().describe("Object key/path"),
    ttl_seconds: z.number().optional().default(300).describe("URL expiry in seconds (max 3600)"),
  },
  async ({ bucket, key, ttl_seconds }) => {
    const effectiveTtl = Math.min(ttl_seconds, 3600);

    try {
      const url = await presigner.getDownloadUrl(bucket, key, effectiveTtl);
      const info = await minio.statObject(bucket, key);

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            url,
            expires_in: effectiveTtl,
            bucket,
            key,
            size_bytes: info.size,
            content_type: info.metaData?.["content-type"] || "unknown",
            last_modified: info.lastModified?.toISOString(),
            warning: "URL expires automatically. Request a new URL if needed.",
          }, null, 2),
        }],
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `File not found or access denied: ${error}` }],
        isError: true,
      };
    }
  }
);

// Tool: Search files by metadata
server.tool(
  "search_files",
  "Search for files in MinIO by metadata tags and prefix",
  {
    bucket: z.string().describe("Bucket name to search"),
    prefix: z.string().optional().default("").describe("Key prefix filter (e.g., 'reports/2026/')"),
    tags: z.record(z.string()).optional().describe("Metadata tag filters (e.g., {'department': 'engineering'})"),
    max_results: z.number().optional().default(20).describe("Maximum results to return"),
  },
  async ({ bucket, prefix, tags, max_results }) => {
    try {
      const results = await metadata.search(bucket, prefix, tags, max_results);

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            bucket,
            prefix,
            filters: tags,
            count: results.length,
            files: results.map((r) => ({
              key: r.key,
              size: r.size,
              last_modified: r.lastModified?.toISOString(),
              tags: r.tags,
            })),
          }, null, 2),
        }],
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `Search failed: ${error}` }],
        isError: true,
      };
    }
  }
);

// Tool: Upload file with metadata
server.tool(
  "upload_file",
  "Upload a file to MinIO with metadata tags",
  {
    bucket: z.string().describe("Bucket name"),
    key: z.string().describe("Object key/path"),
    content_base64: z.string().describe("File content as base64"),
    content_type: z.string().optional().default("application/octet-stream").describe("MIME type"),
    tags: z.record(z.string()).optional().default({}).describe("Metadata tags"),
  },
  async ({ bucket, key, content_base64, content_type, tags }) => {
    try {
      const buffer = Buffer.from(content_base64, "base64");

      await minio.putObject(bucket, key, buffer, buffer.length, {
        "Content-Type": content_type,
        ...Object.fromEntries(Object.entries(tags).map(([k, v]) => [`x-amz-meta-${k}`, v])),
      });

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            uploaded: true,
            bucket,
            key,
            size_bytes: buffer.length,
            content_type,
            tags,
          }, null, 2),
        }],
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `Upload failed: ${error}` }],
        isError: true,
      };
    }
  }
);

// Tool: List buckets
server.tool(
  "list_buckets",
  "List all accessible MinIO buckets",
  {},
  async () => {
    try {
      const buckets = await minio.listBuckets();
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            buckets: buckets.map((b) => ({
              name: b.name,
              created: b.creationDate?.toISOString(),
            })),
          }, null, 2),
        }],
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `Failed to list buckets: ${error}` }],
        isError: true,
      };
    }
  }
);

// Tool: Get file metadata (without downloading)
server.tool(
  "get_file_metadata",
  "Get metadata and tags for a file without downloading it",
  {
    bucket: z.string().describe("Bucket name"),
    key: z.string().describe("Object key/path"),
  },
  async ({ bucket, key }) => {
    try {
      const info = await minio.statObject(bucket, key);
      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            bucket,
            key,
            size_bytes: info.size,
            content_type: info.metaData?.["content-type"],
            last_modified: info.lastModified?.toISOString(),
            etag: info.etag,
            tags: Object.fromEntries(
              Object.entries(info.metaData || {}).filter(([k]) => k.startsWith("x-amz-meta-"))
            ),
          }, null, 2),
        }],
      };
    } catch (error) {
      return {
        content: [{ type: "text", text: `Metadata fetch failed: ${error}` }],
        isError: true,
      };
    }
  }
);

server.start({ transport: "stdio" });

Presigned URL Security

// src/presigner.ts
import { Client } from "minio";

export class Presigner {
  private client: Client;
  private defaultTtl: number;

  constructor(client: Client, defaultTtl: number) {
    this.client = client;
    this.defaultTtl = defaultTtl;
  }

  async getDownloadUrl(
    bucket: string,
    key: string,
    ttlSeconds?: number
  ): Promise<string> {
    const expiry = ttlSeconds || this.defaultTtl;
    return this.client.presignedGetObject(bucket, key, expiry);
  }

  async getUploadUrl(
    bucket: string,
    key: string,
    ttlSeconds?: number
  ): Promise<string> {
    const expiry = ttlSeconds || this.defaultTtl;
    return this.client.presignedPutObject(bucket, key, expiry);
  }
}

Configuration

# config.yaml
minio:
  endpoint: localhost:9000
  use_ssl: true
  access_key: ${MINIO_ACCESS_KEY}
  secret_key: ${MINIO_SECRET_KEY}
  default_ttl: 300
  max_ttl: 3600
  allowed_buckets:
    - agent-documents
    - agent-reports
    - agent-datasets
  denied_buckets:
    - admin-backups
    - system-logs

mcp:
  name: minio-documents
  transport: stdio
// .cursor/mcp.json
{
  "mcpServers": {
    "minio-documents": {
      "command": "node",
      "args": ["dist/server.js"],
      "env": {
        "MINIO_ENDPOINT": "minio.internal:9000",
        "MINIO_ACCESS_KEY": "agent-reader",
        "MINIO_SECRET_KEY": "your-secret-key",
        "PRESIGN_TTL": "300"
      }
    }
  }
}

Security Hardening

  1. Dedicated MinIO User: Create a MinIO service account with read/write access only to agent-allowed buckets. Never use the root admin credentials.
  2. Bucket Policies: Apply bucket-level policies that restrict the agent service account to specific prefixes (e.g., agent-documents/reports/*).
  3. Presigned URL TTL: Enforce maximum TTL of 3600 seconds. Shorter TTLs (300s) reduce the window for URL interception.
  4. Audit Logging: Enable MinIO audit logging to a write-only destination. Every presignedGetObject call generates an audit event.
  5. Content Validation: Validate uploaded content types against an allowlist. Reject executable files (.exe, .sh, .bat) to prevent agent-generated malware.

Last tested: August 2026 with TypeScript 5.5, FastMCP 1.2.0, MinIO 8.0, minio-js 8.0, and Node v22.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

🎉 Thank You for Subscribing!

Frequently Asked Questions
Yes, the upload_file tool generates presigned upload URLs with configurable TTL. Agents can upload analysis results, generated reports, or processed datasets. Uploaded content is validated against a content-type allowlist to prevent storage of executable files.
Agents retrieve document URLs via get_file_url, download content, and feed it into their RAG pipeline. The metadata search tool enables semantic discovery of relevant documents by tags. For large-scale RAG, combine this with a vector store—use MinIO for raw document storage and a separate embedding index for retrieval.
Deepak Bagada
Author Profile

Deepak Bagada

Founder & Editor-in-Chief

Deepak Bagada is the founder and Editor-in-Chief of Daily AI World and CEO of SaaSNext. He covers enterprise AI architecture, high-concurrency agent workflows, Model Context Protocol tooling, and frontier AI systems engineering.

Related Intelligence Analysis

Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc

Cookie & Privacy Preferences

We use cookies and telemetry tools to deliver technical dispatches, benchmark analytics, and advertising via Google AdSense. Review our Privacy Policy.