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
CEO, SaaSNext
- 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.
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
CEO, SaaSNext
Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.
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
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...