MCP server
hybrids3 runs a Model Context Protocol server at /mcp/ on the same port as everything else, using the Streamable HTTP transport. An agent gets seven typed tools for uploading, downloading, listing and deleting objects and for creating presigned URLs. The tools are defined in app/mcp_server.py with FastMCP.
Connecting
{
"mcpServers": {
"hybrids3": {
"type": "streamable-http",
"url": "http://localhost:8080/mcp/"
}
}
}
With Claude Code:
claude mcp add --transport http hybrids3 http://localhost:8080/mcp/
Behind a path_prefix, the endpoint moves with it, for example https://example.com/storage/mcp/. For clients that only run local stdio servers, the OpenClaw plugin bridges stdio to this endpoint.
Authentication
There are two layers, and only the second one decides what a tool may do.
Connection token (optional)
A client may send a token with every MCP request, either as a header or, for clients that cannot set headers, as a query parameter:
Authorization: Bearer <master-key-or-bucket-key>
http://localhost:8080/mcp/?auth=<master-key-or-bucket-key>
A token that matches neither the master key nor any bucket key gets 401 before the request reaches the MCP server. A request without a token passes. A valid token does not grant or limit anything inside the tools: a bucket key here does not restrict the connection to that bucket.
When path_prefix is set, this check does not run, because the middleware looks for paths that start with /mcp before the prefix is removed. A wrong token then passes through like no token. Tool calls are still checked by their auth_key.
Per-tool auth_key
Every tool that touches a bucket takes an auth_key argument: the bucket’s private key or the master key. Reads on a public bucket (download_object, list_objects, object_info) accept an empty auth_key. Uploads, deletes, presigning and anything on a private bucket require it.
download_object(bucket="uploads", key="file.txt")
upload_object(bucket="uploads", key="file.txt", content="hello", auth_key="uploads-secret")
download_object(bucket="permanent", key="doc.pdf", auth_key="perm-secret")
list_buckets(auth_key="your-master-key")
A wrong or missing auth_key gets {"error": "Bucket '<name>' does not exist"}, the same as a bucket that does not exist.
Tools
Every tool returns a JSON object. Success includes "ok": true; failure is {"error": "<message>"}. Clients that support structured output get it as structuredContent, with a text copy for the rest. Unexpected exceptions are masked, so a caller never sees a stack trace.
upload_object
Writes an object, replacing any object with the same key.
| Argument | Default | Meaning |
|---|---|---|
bucket | required | Bucket name |
key | required | Object key, / allowed |
content | required | Text, or base64 when encoding is base64 |
auth_key | required | Bucket key or master key |
content_type | "" | MIME type. Empty means detect it |
encoding | "utf-8" | utf-8 for text, base64 for binary |
Returns ok, size, etag (MD5) and content_type. The bucket’s max_file_size applies to the decoded bytes. The whole object travels inside one JSON-RPC message, so use the HTTP or S3 API for large files.
download_object
| Argument | Default | Meaning |
|---|---|---|
bucket | required | Bucket name |
key | required | Object key |
auth_key | "" | Needed for private buckets |
encoding | "utf-8" | utf-8 returns text, base64 returns base64 |
Returns ok, content, encoding, content_type, size and etag. When encoding is utf-8 and the bytes are not valid UTF-8, the content comes back as base64 and encoding says base64. Objects over 50 MB are refused with an error that points to GET /<bucket>/<key> over HTTP.
delete_object
| Argument | Default | Meaning |
|---|---|---|
bucket | required | Bucket name |
key | required | Object key |
auth_key | required | Bucket key or master key |
Returns {"ok": true}, also when the object did not exist. Deletion cannot be undone.
list_objects
| Argument | Default | Meaning |
|---|---|---|
bucket | required | Bucket name |
auth_key | "" | Needed for private buckets |
prefix | "" | Only keys that start with this text |
max_keys | 100 | Clamped to 1 to 1000 |
Returns ok, bucket, count and objects, a list of {"key", "size", "content_type"} sorted by key.
list_buckets
| Argument | Default | Meaning |
|---|---|---|
auth_key | required | The master key lists every bucket, a bucket key lists its own |
Returns ok and buckets, each with name, public, ttl_seconds and max_file_size_bytes. Keys are never included. A key that matches nothing gets {"error": "Access denied"}.
object_info
| Argument | Default | Meaning |
|---|---|---|
bucket | required | Bucket name |
key | required | Object key |
auth_key | "" | Needed for private buckets |
Returns ok, key, content_type, size, etag, uploaded_at and expires_at (null without a TTL), without the content.
presign_url
| Argument | Default | Meaning |
|---|---|---|
bucket | required | Bucket name |
key | required | Object key |
auth_key | required | Bucket key or master key |
method | "GET" | GET or PUT |
expires | 3600 | Seconds, clamped to 1 to 604800 (7 days) |
base_url | "" | The URL clients use to reach the server, with any path prefix, such as https://example.com/storage |
host | "" | The Host header the URL will be used with. Empty takes it from base_url |
Returns ok, url, method and expires. The MCP server does not know its public address, so always pass base_url; without it the URL has no scheme or host. A signed URL only works when it is requested with the same host it was signed for. Presigned URLs explains the rules, which are the same as for the HTTP endpoint.
Calling it without a client
The server keeps a session per client. Start one with initialize, take the mcp-session-id response header, confirm with notifications/initialized, then send tool calls with that header. Every request needs an Accept header that lists both JSON and server-sent events, and the URL needs its trailing slash; /mcp without it is answered by the bucket routes.
MCP=http://localhost:8080/mcp/
SID=$(
curl -s -D - -o /dev/null "$MCP" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' |
awk -F': ' 'tolower($1) == "mcp-session-id" { print $2 }' |
tr -d '\r'
)
curl -s "$MCP" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
curl -s "$MCP" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "Mcp-Session-Id: $SID" \
--data @- <<'EOF'
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "upload_object",
"arguments": {
"bucket": "uploads",
"key": "note.txt",
"content": "hello",
"auth_key": "uploads-secret"
}
}
}
EOF
The answer arrives as a server-sent event whose data: line holds the JSON-RPC result. A request without a session ID gets 400 with Bad Request: Missing session ID.