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
Founder & Editor-in-Chief
- 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
- Dedicated MinIO User: Create a MinIO service account with read/write access only to agent-allowed buckets. Never use the root admin credentials.
- Bucket Policies: Apply bucket-level policies that restrict the agent service account to specific prefixes (e.g.,
agent-documents/reports/*). - Presigned URL TTL: Enforce maximum TTL of 3600 seconds. Shorter TTLs (300s) reduce the window for URL interception.
- Audit Logging: Enable MinIO audit logging to a write-only destination. Every
presignedGetObjectcall generates an audit event. - 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.
Related Architecture & Implementation Resources
- Browse complementary servers and client connectors in the Daily AI World MCP Directory.
- Integrate this tool into multi-agent pipelines with our AI Workflows Blueprints.
- Review frontier LLM capabilities and token metrics on Latest AI News.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
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.
Build a Zero-Knowledge Agent Identity Verification Workflow with LangGraph & Circom SNARKs in 2026
Next Story →Build a HashiCorp Vault Secrets MCP Server for Agentic Credential Management in 2026
Related Intelligence Analysis
Stop the Burnout: Building an AI Employee Retention Monitor Guide
Build an AI Employee Retention Monitor with FastMCP in Python. Aggregate non-invasive workload telemetries, predict burnout scores, and prevent regretted turnover.
Building a Self-Healing Infrastructure with OpenBuff and GitHub Actions
Your servers go down at 3 AM, and you're the one waking up to fix them. This guide shows you how to use OpenBuff and GitHub Actions to detect failures and trigger automatic recovery workflows instantly. Stop manual resta...
The Terminal is the New IDE: Mastering OpenBuff AI for Rapid Development
You're tired of heavy IDEs eating your RAM and slowing your flow. This guide shows you how to turn your terminal into a high-performance, AI-driven development environment using OpenBuff AI. Stop context switching and st...