Creating Custom Docker MCP Servers: A Complete Guide

DevOps
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 TypeWhere It LivesUI VisibilityApproval Required
Official Docker CatalogDocker Hub (mcp/*)Docker Desktop UI + CLIYes - PR to docker/mcp-registry
Custom CatalogLocal YAML filesCLI + Connected Clients OnlyNo - 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:

  1. SSH into the NAS
  2. Run a restore script to fix NFS exports
  3. Verify the exports were correct
  4. 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

  1. Verify version: 2 in your catalog YAML
  2. Check the path in catalog.json is absolute and correct
  3. Run docker mcp catalog update
  4. Validate YAML syntax (use a linter)

Tools Not Accessible in Client

  1. Restart your AI client (Claude Code, Cursor, etc.)
  2. Verify secrets are set: docker mcp secret ls
  3. 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

CommandPurpose
docker mcp catalog updateRefresh catalog cache
docker mcp catalog show customDisplay custom catalog
docker mcp server lsList enabled servers
docker mcp server enable <name>Enable a server
docker mcp server disable <name>Disable a server
docker mcp tools lsList all available tools
docker mcp secret set <name>Set a secret (pipe value in)
docker mcp secret lsList configured secrets
docker mcp gateway runManually run gateway (debugging)

Key Takeaways

  1. 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.

  2. Version 2 is critical - Your custom catalog must use version: 2 to match the official Docker catalog format. Version 3 causes silent failures.

  3. Four files must sync - The catalog YAML, catalog.json, registry.yaml, and secrets all need to reference the same server name consistently.

  4. The mcp/ prefix is convention - Tag your images as mcp/your-server:latest for consistency with Docker’s official servers.

  5. Debug with manual container runs - When things break silently, run your container directly with -it to 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.