Creating Custom Docker MCP Servers: A Complete Guide
Table of Contents
If you’ve been using Docker’s MCP Toolkit to supercharge your AI workflows, you’ve probably wondered: “Can I build my own MCP server and integrate it with this system?” The answer is yes—but the documentation is scattered and the process has some gotchas that can trip you up. This guide walks you through everything you need to know to create, deploy, and troubleshoot custom MCP servers that work seamlessly with the Docker MCP Gateway.
Part 1: Understanding the Docker MCP Architecture
The Two Catalog System
Docker MCP operates with two distinct types of catalogs, and understanding this distinction is crucial:
| Catalog Type | Where It Lives | UI Visibility | Approval Required |
|---|---|---|---|
| Official Docker Catalog | Docker Hub (mcp/*) | Docker Desktop UI + CLI | Yes - PR to docker/mcp-registry |
| Custom Catalog | Local YAML files | CLI + Connected Clients Only | No - Self-managed |
Critical insight: Your custom MCP servers will work perfectly through the Docker MCP Gateway and appear in connected AI clients (Claude, Cursor, VS Code, etc.), but they will NOT appear in Docker Desktop’s MCP Toolkit UI. This isn’t a bug—it’s expected behavior. The Desktop UI only displays officially approved servers from Docker’s curated catalog.
How the Gateway Routes Traffic
┌─────────────────────────────────────────────────────────────┐
│ AI Client (Claude, Cursor) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Docker MCP Gateway │
│ • Routes requests to appropriate MCP servers │
│ • Manages container lifecycle automatically │
│ • Injects secrets as environment variables │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Official │ │ Official │ │ Custom │
│ Server │ │ Server │ │ Server │
│ (mcp/*) │ │ (mcp/*) │ │ (local) │
└──────────┘ └──────────┘ └──────────┘
The Gateway eliminates the N×M configuration problem. Instead of configuring each server for each client, you configure servers once and the Gateway handles all the routing.
Part 2: Building Your MCP Server
Project Structure
Create a dedicated directory for your server:
~/.mcp-servers/your-server-name/
├── server.py # Main MCP server code
├── Dockerfile # Container definition
└── requirements.txt # (optional) Python dependencies
Writing the Server Code
MCP servers communicate over STDIO (standard input/output). Here’s a complete template:
#!/usr/bin/env python3
import os
import asyncio
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
# Configuration from environment variables (injected by Gateway)
HOST = os.environ.get("YOUR_HOST", "localhost")
USER = os.environ.get("YOUR_USER", "")
PASSWORD = os.environ.get("YOUR_PASSWORD", "")
server = Server("your-server-name")
@server.list_tools()
async def list_tools():
"""Declare available tools to the AI client."""
return [
Tool(
name="your_tool_name",
description="What this tool does - be descriptive!",
inputSchema={
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The command to execute"
}
},
"required": ["command"]
}
),
Tool(
name="status_check",
description="Check connection status",
inputSchema={
"type": "object",
"properties": {}
}
)
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
"""Execute tools when called by the AI client."""
if name == "your_tool_name":
command = arguments.get("command", "")
# Your implementation here
result = f"Executed: {command}"
return [TextContent(type="text", text=result)]
elif name == "status_check":
return [TextContent(type="text", text=f"Connected to {HOST}")]
return [TextContent(type="text", text=f"Unknown tool: {name}")]
async def main():
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
asyncio.run(main())
Creating the Dockerfile
Keep it simple—the MCP library handles the communication protocol:
FROM python:3.12-slim
WORKDIR /app
# Install MCP and any additional dependencies
RUN pip install --no-cache-dir mcp paramiko requests
COPY server.py .
CMD ["python", "server.py"]
Building the Image
cd ~/.mcp-servers/your-server-name
docker build -t mcp/your-server-name:latest .
Pro tip: Always use the mcp/ prefix for your image names. This follows Docker’s convention and keeps your custom servers visually organized alongside official ones.
Part 3: Real-World Example - SSH NAS Server
Let’s walk through a complete, production-ready example: an MCP server that allows Claude to SSH into a NAS (Network Attached Storage) to execute commands. This is the exact server I built to manage my homelab NAS directly from Claude Code.
The Use Case
The problem: My UGREEN NAS firmware updates reset /etc/exports to restrictive defaults, breaking Kubernetes NFS storage. PostgreSQL pods would crashloop with permission errors like:
chmod /var/lib/postgresql/data/pgdata: operation not permitted
Every time the NAS updated, I had to:
- SSH into the NAS
- Run a restore script to fix NFS exports
- Verify the exports were correct
- Check that K8s pods recovered
Instead of constantly switching to a terminal, I built an MCP server that gives Claude direct SSH access. Now I can say “check if the NFS exports are correct on the NAS” or “run the restore script” and Claude handles it directly.
Complete Server Code
File: ~/.mcp-servers/ssh-nas/server.py
#!/usr/bin/env python3
"""
SSH NAS MCP Server - Allows Claude to SSH into a NAS and execute commands.
"""
import asyncio
import os
import sys
from typing import Any
try:
import paramiko
except ImportError:
print("Installing paramiko...", file=sys.stderr)
import subprocess
subprocess.check_call([sys.executable, "-m", "pip", "install", "paramiko", "-q"])
import paramiko
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
# Configuration from environment variables
NAS_HOST = os.environ.get("NAS_HOST", "")
NAS_PORT = int(os.environ.get("NAS_PORT", "22"))
NAS_USER = os.environ.get("NAS_USER", "")
NAS_PASSWORD = os.environ.get("NAS_PASSWORD", "")
server = Server("ssh-nas")
def execute_ssh_command(command: str, timeout: int = 30) -> dict[str, Any]:
"""Execute a command on the NAS via SSH."""
if not all([NAS_HOST, NAS_USER, NAS_PASSWORD]):
return {
"success": False,
"error": "NAS credentials not configured. Set NAS_HOST, NAS_USER, and NAS_PASSWORD environment variables.",
}
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
client.connect(
hostname=NAS_HOST,
port=NAS_PORT,
username=NAS_USER,
password=NAS_PASSWORD,
timeout=timeout,
)
stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
exit_status = stdout.channel.recv_exit_status()
stdout_text = stdout.read().decode("utf-8", errors="replace")
stderr_text = stderr.read().decode("utf-8", errors="replace")
return {
"success": exit_status == 0,
"exit_code": exit_status,
"stdout": stdout_text,
"stderr": stderr_text,
}
except paramiko.AuthenticationException:
return {"success": False, "error": "Authentication failed. Check username and password."}
except paramiko.SSHException as e:
return {"success": False, "error": f"SSH error: {str(e)}"}
except Exception as e:
return {"success": False, "error": f"Connection error: {str(e)}"}
finally:
client.close()
@server.list_tools()
async def list_tools() -> list[Tool]:
"""List available SSH tools."""
return [
Tool(
name="ssh_execute",
description=f"Execute a command on the NAS ({NAS_HOST or 'not configured'}) via SSH. Use this to run shell commands, check system status, manage files, etc.",
inputSchema={
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute on the NAS",
},
"timeout": {
"type": "integer",
"description": "Command timeout in seconds (default: 30)",
"default": 30,
},
},
"required": ["command"],
},
),
Tool(
name="ssh_status",
description="Check the SSH connection status to the NAS",
inputSchema={
"type": "object",
"properties": {},
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
"""Handle tool calls."""
if name == "ssh_execute":
command = arguments.get("command", "")
timeout = arguments.get("timeout", 30)
if not command:
return [TextContent(type="text", text="Error: No command provided")]
result = await asyncio.get_event_loop().run_in_executor(
None, lambda: execute_ssh_command(command, timeout)
)
if result.get("success"):
output = f"Exit code: {result['exit_code']}\n"
if result.get("stdout"):
output += f"\nSTDOUT:\n{result['stdout']}"
if result.get("stderr"):
output += f"\nSTDERR:\n{result['stderr']}"
return [TextContent(type="text", text=output)]
else:
error_msg = result.get("error", "Unknown error")
stderr = result.get("stderr", "")
return [TextContent(type="text", text=f"Error: {error_msg}\n{stderr}")]
elif name == "ssh_status":
if not all([NAS_HOST, NAS_USER, NAS_PASSWORD]):
return [TextContent(
type="text",
text="NAS not configured. Required environment variables:\n"
"- NAS_HOST: IP address or hostname\n"
"- NAS_USER: SSH username\n"
"- NAS_PASSWORD: SSH password\n"
"- NAS_PORT: SSH port (optional, default 22)"
)]
# Test connection
result = await asyncio.get_event_loop().run_in_executor(
None, lambda: execute_ssh_command("echo 'Connection successful'", 10)
)
if result.get("success"):
return [TextContent(
type="text",
text=f"Connected to NAS\n"
f"Host: {NAS_HOST}:{NAS_PORT}\n"
f"User: {NAS_USER}\n"
f"Status: Online and accessible"
)]
else:
return [TextContent(
type="text",
text=f"Connection failed\n"
f"Host: {NAS_HOST}:{NAS_PORT}\n"
f"User: {NAS_USER}\n"
f"Error: {result.get('error', 'Unknown error')}"
)]
return [TextContent(type="text", text=f"Unknown tool: {name}")]
async def main():
"""Run the MCP server."""
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
if __name__ == "__main__":
asyncio.run(main())
Key Implementation Details
Dynamic tool descriptions: Notice how the tool description includes {NAS_HOST or 'not configured'}. This means the AI client sees the actual host in the tool list, making it clear which server it’s connecting to.
Async execution: The SSH calls are blocking, but we wrap them in run_in_executor to keep the async server responsive. This is important for long-running commands.
Graceful error handling: The server handles authentication failures, connection timeouts, and SSH errors separately, giving the AI useful feedback to report to the user.
Dockerfile
File: ~/.mcp-servers/ssh-nas/Dockerfile
FROM python:3.12-slim
WORKDIR /app
# Install dependencies
RUN pip install --no-cache-dir mcp paramiko
# Copy server code
COPY server.py .
# Run the MCP server
CMD ["python", "server.py"]
Catalog Configuration
Add this to your ~/.docker/mcp/catalogs/custom-catalog.yaml:
ssh-nas:
description: SSH into NAS for remote command execution
title: SSH NAS Server
type: server
dateAdded: "2025-12-16T00:00:00Z"
image: mcp/ssh-nas:latest
ref: ""
tools:
- name: ssh_execute
- name: ssh_status
secrets:
- name: ssh-nas.host
env: NAS_HOST
example: 192.168.1.100
description: NAS IP address or hostname
- name: ssh-nas.user
env: NAS_USER
example: admin
description: SSH username
- name: ssh-nas.password
env: NAS_PASSWORD
example: secret
description: SSH password
- name: ssh-nas.port
env: NAS_PORT
example: "22"
description: SSH port (optional)
prompts: 0
resources: {}
metadata:
category: devops
tags:
- custom
- ssh
- nas
- homelab
Setting Up the Secrets
echo "192.168.1.100" | docker mcp secret set ssh-nas.host
echo "admin" | docker mcp secret set ssh-nas.user
echo "your-password" | docker mcp secret set ssh-nas.password
echo "22" | docker mcp secret set ssh-nas.port
Using It in Practice
Once enabled, I can ask Claude things like:
- “Check the NFS exports on the NAS” → runs
cat /etc/exports - “Run the NFS restore script” → runs
sudo /volume1/scripts/restore-nfs-exports.sh - “Check the NFS restore log” → runs
cat /var/log/nfs-restore.log - “Verify active NFS exports” → runs
sudo exportfs -v - “Check disk space on the NAS” → runs
df -h
Claude uses the ssh_execute tool transparently, and I get the results directly in our conversation. When my K8s pods start failing after a NAS update, I just ask Claude to fix it—no context switching required.
Part 4: Configuring the Docker MCP System
This is where most people get stuck. There are four files you need to configure, and they must all be in sync.
File 1: The Custom Catalog
Location: ~/.docker/mcp/catalogs/custom-catalog.yaml
version: 2
name: custom
displayName: Custom MCP Servers
registry:
your-server-name:
description: Brief description of your server's purpose
title: Your Server Title
type: server
dateAdded: "2025-12-16T00:00:00Z"
image: mcp/your-server-name:latest
ref: ""
tools:
- name: your_tool_name
- name: status_check
secrets:
- name: your-server-name.host
env: YOUR_HOST
example: 192.168.1.100
description: The host to connect to
- name: your-server-name.user
env: YOUR_USER
example: admin
description: Username for authentication
- name: your-server-name.password
env: YOUR_PASSWORD
example: secret
description: Password for authentication
prompts: 0
resources: {}
metadata:
category: devops
tags:
- custom
- your-tag
Critical: Use version: 2. The official Docker catalog uses version 2, and using version 3 will cause silent failures where docker mcp server inspect won’t find your server.
File 2: The Catalog Registry
Location: ~/.docker/mcp/catalog.json
{
"catalogs": {
"custom": {
"displayName": "Custom MCP Servers",
"url": "/Users/YOUR_USERNAME/.docker/mcp/catalogs/custom-catalog.yaml",
"lastUpdate": "2025-12-16T00:00:00Z"
},
"docker-mcp": {
"displayName": "Docker MCP Catalog",
"url": "https://desktop.docker.com/mcp/catalog/v2/catalog.yaml"
}
}
}
File 3: The Server Registry
Location: ~/.docker/mcp/registry.yaml
registry:
your-server-name:
ref: ""
# ... other enabled servers
File 4: Secrets (Managed via CLI)
Don’t edit this directly—use the CLI:
echo "your-host-value" | docker mcp secret set your-server-name.host
echo "your-username" | docker mcp secret set your-server-name.user
echo "your-password" | docker mcp secret set your-server-name.password
Part 5: Activation and Verification
Refresh and Enable
# Update the catalog cache
docker mcp catalog update
# Enable your server
docker mcp server enable your-server-name
Verification Checklist
# 1. Server appears in list
docker mcp server ls
# 2. Tools are registered
docker mcp tools ls | grep your_tool
# 3. Secrets are configured
docker mcp secret ls | grep your-server-name
# 4. Custom catalog shows your server
docker mcp catalog show custom
What About docker mcp server inspect?
Here’s a gotcha: running docker mcp server inspect your-server-name will return “server not found in catalog” for custom servers. This is normal. The inspect command only works with the official Docker catalog. Your server is working correctly if it appears in docker mcp server ls.
Part 6: Troubleshooting Common Issues
Server Not Appearing
- Verify
version: 2in your catalog YAML - Check the path in
catalog.jsonis absolute and correct - Run
docker mcp catalog update - Validate YAML syntax (use a linter)
Tools Not Accessible in Client
- Restart your AI client (Claude Code, Cursor, etc.)
- Verify secrets are set:
docker mcp secret ls - Check the Docker image exists:
docker images | grep your-server
Container Crashes
Debug by running manually:
docker run -it --rm \
-e YOUR_HOST=value \
-e YOUR_USER=value \
-e YOUR_PASSWORD=value \
mcp/your-server-name:latest
This shows you the actual error output instead of silent failures.
Part 7: Command Reference
| Command | Purpose |
|---|---|
docker mcp catalog update | Refresh catalog cache |
docker mcp catalog show custom | Display custom catalog |
docker mcp server ls | List enabled servers |
docker mcp server enable <name> | Enable a server |
docker mcp server disable <name> | Disable a server |
docker mcp tools ls | List all available tools |
docker mcp secret set <name> | Set a secret (pipe value in) |
docker mcp secret ls | List configured secrets |
docker mcp gateway run | Manually run gateway (debugging) |
Key Takeaways
Custom servers work, but invisibly - They function through the Gateway and appear in AI clients, but not in Docker Desktop’s UI. This is by design, not a bug.
Version 2 is critical - Your custom catalog must use
version: 2to match the official Docker catalog format. Version 3 causes silent failures.Four files must sync - The catalog YAML, catalog.json, registry.yaml, and secrets all need to reference the same server name consistently.
The
mcp/prefix is convention - Tag your images asmcp/your-server:latestfor consistency with Docker’s official servers.Debug with manual container runs - When things break silently, run your container directly with
-itto see actual error output.
Future Considerations
Docker has indicated that managing custom servers directly in the Desktop UI is planned for future releases. For now, the CLI-based workflow is fully functional—it’s just not as discoverable as the official catalog experience.
For submitting servers to the official catalog (if you want that Desktop UI visibility), see the docker/mcp-registry contribution guidelines.