Packaging an MCP server as a Docker image gives clients one launch command with no Python, no uv, and no virtual environment to manage on the host: they run the container. This tutorial builds a small, non-root image for a FastMCP server with a multi-stage build, runs it over stdio the way a client does, and wires it into a client’s .mcp.json with docker run.

The build uses uv in the builder stage and copies only the finished virtual environment into a slim runtime stage, so the final image carries the app and its dependencies — not the build toolchain.

What you will build

  • An installable FastMCP package (mcp-docker-demo) with a --check smoke command.
  • A multi-stage Dockerfile that builds with uv and ships a non-root python:3.12-slim runtime.
  • A smoke script that launches the image with docker run and speaks MCP to it over stdio.
  • A .mcp.json that registers the container with a client, plus a Makefile.

Prerequisites

  • Docker (Docker Desktop on macOS). Verify the daemon is running with docker version. Validated on Docker 29.6.1.
  • uv 0.11+ (brew install uv) for local development and the stdio smoke test. Validated on uv 0.11.26, Python 3.12.9, FastMCP 3.4.4.
  • Familiarity with FastMCP (tools, the stdio transport).

Step 1: Scaffold the package

Use uv’s packaged layout so the project has a src/ module and a console script — the command the image’s entrypoint runs.

Create the files

mkdir -p dockerize-mcp-server-macos
cd dockerize-mcp-server-macos
touch .gitignore
uv init --package --name mcp-docker-demo .
uv add "fastmcp>=3.4.4"
uv add --dev "pytest>=9" "pytest-asyncio>=1.4"
mkdir -p tests

Add the code: .gitignore

.venv/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
dist/
.DS_Store
*.log

Add the code: pyproject.toml

[project]
name = "mcp-docker-demo"
version = "0.1.0"
description = "A FastMCP server packaged as a small, non-root Docker image"
readme = "README.md"
authors = [
    { name = "Your Name", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = [
    "fastmcp>=3.4.4",
]

[project.scripts]
mcp-docker-demo = "mcp_docker_demo:main"

[build-system]
requires = ["uv_build>=0.11.26,<0.12.0"]
build-backend = "uv_build"

[dependency-groups]
dev = [
    "pytest>=9",
    "pytest-asyncio>=1.4",
]

Detailed breakdown

  • uv init --package creates the src/mcp_docker_demo/ layout, the [project.scripts] console script, and the uv_build backend. The console script (mcp-docker-demo) is what the container runs as its entrypoint.
  • requires-python = ">=3.12" matters for Docker: the runtime base image must provide a matching interpreter (Step 5 uses python:3.12-slim).
  • The lockfile uv.lock (written by uv add) is what the build installs from, so the image gets the exact pinned dependency versions.

Step 2: Write the server

Create the file

touch src/mcp_docker_demo/server.py

Add the code: src/mcp_docker_demo/server.py

"""A small, installable FastMCP server.

Nothing here is Docker-specific — the point of this project is packaging it into a
small, non-root image. Transport is chosen at runtime so the same entry point
works as a stdio subprocess (what a client launches, including via `docker run`)
or over HTTP.
"""

import os

from fastmcp import FastMCP

mcp = FastMCP("mcp-docker-demo")


@mcp.tool
def greet(name: str) -> str:
    """Return a greeting for name."""
    return f"Hello, {name}! Served from a container."


@mcp.tool
def add(a: int, b: int) -> int:
    """Add two integers and return the sum."""
    return a + b


def run() -> None:
    """Start the server on the transport named by MCP_TRANSPORT (default stdio)."""
    if os.environ.get("MCP_TRANSPORT", "stdio").lower() == "http":
        mcp.run(
            transport="http",
            host=os.environ.get("MCP_HOST", "0.0.0.0"),
            port=int(os.environ.get("MCP_PORT", "8000")),
        )
    else:
        mcp.run()  # stdio: launched as a subprocess by a client

Detailed breakdown

  • Two tools (greet, add) give the container something to answer. The focus is packaging, not the server surface.
  • run picks the transport from MCP_TRANSPORT. The default stdio path is what a client launches with docker run -i. The HTTP branch binds 0.0.0.0 (not 127.0.0.1) because inside a container the server must listen on all interfaces for a published port to reach it — a common Docker gotcha even though this tutorial focuses on stdio.

Step 3: The package entry point

Create the file

uv init --package created src/mcp_docker_demo/__init__.py. Replace its contents.

Add the code: src/mcp_docker_demo/__init__.py

"""Package entry point.

`main` is the target of the `mcp-docker-demo` console script declared in
pyproject.toml. The image's ENTRYPOINT runs that script, so `docker run` starts
the server. A `--check` flag lists the tools and exits — a fast way to prove the
image is wired correctly without starting the server.
"""

import asyncio
import sys

from .server import mcp, run


def _check() -> None:
    """Print server identity and tools, then exit — verifies the image."""
    tools = asyncio.run(mcp.list_tools())
    names = sorted(t.name for t in tools)
    print(f"mcp-docker-demo OK — tools: {names}")


def main() -> None:
    if "--check" in sys.argv[1:]:
        _check()
        return
    run()

Detailed breakdown

  • main is the [project.scripts] target, so the image entrypoint (mcp-docker-demo) calls it. With no flag it starts the server; with --check it lists tools and exits 0.
  • --check is the image smoke test. Running docker run --rm <image> --check proves the container’s entrypoint resolves, the package imports, and the tools register — without opening a stdio session.

Step 4: Tests

Create the files

touch pytest.ini tests/test_server.py

Add the code: pytest.ini

[pytest]
asyncio_mode = auto
filterwarnings =
    ignore::DeprecationWarning

Add the code: tests/test_server.py

"""Drive the server in-memory so the code is proven before it is containerized."""

from fastmcp import Client

from mcp_docker_demo.server import mcp


async def test_tools_are_registered():
    async with Client(mcp) as client:
        names = sorted(t.name for t in await client.list_tools())
    assert names == ["add", "greet"]


async def test_greet():
    async with Client(mcp) as client:
        result = await client.call_tool("greet", {"name": "Ada"})
    assert result.data == "Hello, Ada! Served from a container."


async def test_add():
    async with Client(mcp) as client:
        result = await client.call_tool("add", {"a": 40, "b": 2})
    assert result.data == 42

Run them:

uv run pytest -q
...                                                                      [100%]
3 passed in 1.38s

Detailed breakdown

  • Test the code in-memory before containerizing. These run in the host environment against the installed package, so a logic bug fails here in a second rather than after a full image build. The container smoke test (Step 7) then proves the packaging.

Step 5: The Dockerfile

This is the deliverable. A builder stage installs dependencies and the project with uv; a runtime stage copies only the finished virtual environment into a slim, non-root image.

Create the file

touch Dockerfile

Add the code: Dockerfile

# syntax=docker/dockerfile:1

# ---- build stage ---------------------------------------------------------
# The uv image is python:3.12-slim-bookworm plus the uv binary, so the venv it
# builds uses /usr/local/bin/python3.12 — the same interpreter path the runtime
# stage has. That is what makes copying the venv across stages work.
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder

ENV UV_COMPILE_BYTECODE=1 \
    UV_LINK_MODE=copy \
    UV_PYTHON_DOWNLOADS=0

WORKDIR /app

# Install dependencies first, without the project, so this layer is cached until
# the lockfile changes.
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-install-project --no-dev

# Then copy the source and install the project itself into the same venv.
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --locked --no-dev

# ---- runtime stage -------------------------------------------------------
FROM python:3.12-slim-bookworm

# Create an unprivileged user; the server never needs root.
RUN groupadd --system app && useradd --system --gid app --home-dir /app app

WORKDIR /app

# Copy only the built application (source + venv), owned by the non-root user.
COPY --from=builder --chown=app:app /app /app

# Put the venv on PATH so the console script resolves without activation.
ENV PATH="/app/.venv/bin:$PATH"

USER app

# stdio transport: a client launches the container and speaks JSON-RPC over
# stdin/stdout. Run the console script directly as PID 1.
ENTRYPOINT ["mcp-docker-demo"]

Detailed breakdown

  • # syntax=docker/dockerfile:1 enables the RUN --mount=type=cache build cache used below (Docker’s default BuildKit builder supports it).
  • The builder base is ghcr.io/astral-sh/uv:python3.12-bookworm-slim. That image is python:3.12-slim-bookworm with the uv binary added, so the venv it creates points at /usr/local/bin/python3.12 — the same path the runtime stage provides. Matching the interpreter path is what makes the copied venv run in the second stage.
  • The three ENV flags tune uv for images. UV_COMPILE_BYTECODE=1 precompiles .pyc for faster startup; UV_LINK_MODE=copy copies packages into the venv instead of hardlinking to the cache (the cache is a separate mount); UV_PYTHON_DOWNLOADS=0 forbids downloading a standalone interpreter, so uv uses the image’s python.
  • Dependencies install before the project. COPY pyproject.toml uv.lock then uv sync --no-install-project builds the dependency layer, which Docker caches and reuses until the lockfile changes — only your own code re-installs on a typical edit. --no-dev omits test dependencies from the image.
  • The runtime stage copies just /app (source plus .venv) from the builder, with no uv and no build cache. ENV PATH=/app/.venv/bin:$PATH puts the console script on PATH.
  • USER app drops root. The image runs as an unprivileged user, so a compromise inside the container does not start as root. ENTRYPOINT ["mcp-docker-demo"] runs the server as PID 1; arguments passed to docker run after the image name (like --check) become the script’s argv.

Step 6: The .dockerignore

Keep the build context small and deterministic so the host’s virtual environment and caches never ship into the image.

Create the file

touch .dockerignore

Add the code: .dockerignore

# Keep the build context small and reproducible.
.venv/
dist/
__pycache__/
*.pyc
.pytest_cache/
.ruff_cache/
.git/
.gitignore
.DS_Store
*.log
Dockerfile
.dockerignore
tests/
smoke.py
.mcp.json
Makefile

Detailed breakdown

  • .venv/ is the most important line. Without it, COPY . . would copy the host’s macOS virtual environment into the Linux image, bloating it and shadowing the venv the build creates. The build makes its own venv, so the host’s must never enter the context.
  • Test files, the Makefile, and the smoke script are not needed at runtime, so excluding them trims the context and the final image.

Step 7: Build and inspect the image

Build it:

docker build -t mcp-docker-demo:latest .
 => naming to docker.io/library/mcp-docker-demo:latest

Confirm it is small-ish and runs as a non-root user, then run the --check smoke inside the container:

docker images mcp-docker-demo:latest --format '{{.Repository}}:{{.Tag}}  {{.Size}}'
docker run --rm --entrypoint id mcp-docker-demo:latest
docker run --rm mcp-docker-demo:latest --check
mcp-docker-demo:latest  340MB
uid=999(app) gid=999(app) groups=999(app)
mcp-docker-demo OK — tools: ['add', 'greet']

Detailed breakdown

  • docker run --entrypoint id overrides the entrypoint to run id, proving the container’s default user is app (uid 999), not root.
  • docker run <image> --check appends --check to the entrypoint, so the container lists its tools and exits. A green line here means the packaged server imports and registers correctly.
  • The image is ~340 MB, dominated by the dependency tree FastMCP pulls in (Starlette, Uvicorn, Pydantic, and more), not the base. The multi-stage build keeps the uv toolchain and build cache out of it; the “Next improvements” section notes how a distroless base trims the runtime layer further.

Step 8: Smoke-test over stdio with docker run

The --check flag proves the image starts, but a client talks to it over a full MCP session. Launch the container the way a client does and call its tools.

Create the file

touch smoke.py

Add the code: smoke.py

"""Launch the built image with `docker run` and speak MCP to it over stdio.

A green run proves the exact command a client puts in `.mcp.json` works: the
container starts, completes the MCP handshake, and answers tool calls. Build the
image first (`make image`), then run this from the project root.
"""

import asyncio

from fastmcp import Client
from fastmcp.client.transports import StdioTransport

IMAGE = "mcp-docker-demo:latest"


async def main() -> None:
    # `docker run -i --rm <image>` is the same command that goes in .mcp.json.
    # -i keeps stdin open so the stdio transport has a channel; --rm cleans up.
    transport = StdioTransport(
        command="docker",
        args=["run", "-i", "--rm", IMAGE],
    )
    async with Client(transport) as client:
        tools = sorted(t.name for t in await client.list_tools())
        print("tools:", tools)

        greeting = await client.call_tool("greet", {"name": "Ada"})
        print("greet ->", greeting.data)

        total = await client.call_tool("add", {"a": 2, "b": 3})
        print("add ->", total.data)


if __name__ == "__main__":
    asyncio.run(main())

Run it from the project root:

uv run python smoke.py
tools: ['add', 'greet']
greet -> Hello, Ada! Served from a container.
add -> 5

Detailed breakdown

  • StdioTransport(command="docker", args=["run", "-i", "--rm", IMAGE]) is the client half of stdio: FastMCP spawns docker run, and the container’s stdin/stdout carry JSON-RPC. This is the exact command a client will launch.
  • -i is required. It keeps the container’s stdin open; without it the stdio transport has no channel and the handshake fails immediately. --rm removes the container when the session ends, so repeated runs do not pile up stopped containers.
  • FastMCP logs a startup banner to stderr (Starting MCP server ... with transport 'stdio'); stdout stays clean for the protocol. The tool results confirm the whole path: the container answered greet and add (2 + 3 = 5).

Step 9: Register the container with a client

A client launches the container with docker run. Commit a project .mcp.json that clients can discover.

Create the file

touch .mcp.json

Add the code: .mcp.json

{
  "mcpServers": {
    "mcp-docker-demo": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "mcp-docker-demo:latest"]
    }
  }
}

Or register it with Claude Code directly (see Register a FastMCP Server with Claude Desktop and Claude Code for scopes):

claude mcp add mcp-docker-demo -- docker run -i --rm mcp-docker-demo:latest
claude mcp get mcp-docker-demo
  Status: ✔ Connected
  Type: stdio
  Command: docker
  Args: run -i --rm mcp-docker-demo:latest

Detailed breakdown

  • The command is docker and the args are the run invocation; a client spawns that process and speaks stdio to the container, exactly as the smoke test did. The image must be built (and present locally) before a client can launch it.
  • ✔ Connected means Claude Code ran the container and completed the MCP handshake. Remove it with claude mcp remove mcp-docker-demo -s local. The committed .mcp.json shows up as pending approval until you approve it in the client — the intended flow for a project-scoped server config.

Step 10: The Makefile

Wrap the workflow so plain make prints help.

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

IMAGE := mcp-docker-demo:latest

.PHONY: help test image size check smoke run register-code unregister-code clean

help: ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*##' $(MAKEFILE_LIST) | \
		awk 'BEGIN {FS = ":.*## "}; {printf "  %-16s %s\n", $$1, $$2}'

test: ## Run the test suite (in-memory, no container)
	uv run pytest -q

image: ## Build the Docker image
	docker build -t $(IMAGE) .

size: ## Show the built image size
	docker images $(IMAGE) --format '{{.Repository}}:{{.Tag}}  {{.Size}}'

check: image ## Build, then run --check inside the container
	docker run --rm $(IMAGE) --check

smoke: image ## Build, then launch the container over stdio and call its tools
	uv run python smoke.py

run: image ## Run the server on stdio in a container (Ctrl-C to stop)
	docker run -i --rm $(IMAGE)

register-code: image ## Register the container with Claude Code (local scope)
	claude mcp add mcp-docker-demo -- docker run -i --rm $(IMAGE)

unregister-code: ## Remove the server from Claude Code
	-claude mcp remove mcp-docker-demo -s local

clean: ## Remove build caches and the image
	rm -rf .pytest_cache .ruff_cache __pycache__ src/*/__pycache__ tests/__pycache__
	-docker image rm $(IMAGE)

Verify the default target prints help:

make
  help             Show this help screen
  test             Run the test suite (in-memory, no container)
  image            Build the Docker image
  size             Show the built image size
  check            Build, then run --check inside the container
  smoke            Build, then launch the container over stdio and call its tools
  run              Run the server on stdio in a container (Ctrl-C to stop)
  register-code    Register the container with Claude Code (local scope)
  unregister-code  Remove the server from Claude Code
  clean            Remove build caches and the image

Detailed breakdown

  • check, smoke, and run depend on image, so the container a target launches is always freshly built. smoke runs the stdio round-trip against the image; check runs the fast in-container list-and-exit.
  • .DEFAULT_GOAL := help prints the target list on bare make. Recipes are tab-indented.

Troubleshooting

  • Cannot connect to the Docker daemon. Start Docker Desktop and wait for it to report running, then retry docker version.
  • The client connects but sees no tools, or the handshake hangs. The run command must include -i so the container’s stdin stays open for stdio. Without it there is no channel for the protocol.
  • The image is much larger than expected. Confirm .dockerignore excludes .venv/; otherwise the host virtual environment is copied into the image. Rebuild after adding it.
  • exec: "mcp-docker-demo": not found. The console script is not on PATH in the runtime stage. Confirm ENV PATH="/app/.venv/bin:$PATH" and that the venv was copied from the builder.
  • The venv copies but fails to run in the runtime stage. The builder and runtime interpreters must match. Keep both on python:3.12 (the uv image and python:3.12-slim-bookworm share /usr/local/bin/python3.12); a version mismatch breaks the copied venv.
  • Anything writes to stdout in the container. On stdio that corrupts the protocol. Keep logging on stderr; never print() to stdout from the server.

Recap

  • A multi-stage build installs with uv in a builder stage and copies only the finished venv into a slim runtime, keeping the toolchain out of the final image.
  • The runtime runs as a non-root user (uid 999) with the console script as the entrypoint, so docker run <image> starts the server and docker run <image> --check smoke-tests it.
  • Clients launch the container over stdio with docker run -i --rm, the same command that goes in .mcp.json and claude mcp add.
  • .dockerignore keeps the context clean so the host venv never ships, and a --check plus a stdio smoke prove the packaged form works.

Next improvements

  • Shrink the runtime with a distroless base (gcr.io/distroless/python3) or a smaller interpreter, trading some debuggability (no shell) for a smaller, harder target.
  • Publish the image to a registry (GHCR, Docker Hub) and reference it by digest in .mcp.json, so clients pull a pinned, immutable image.
  • Add a healthcheck and the HTTP transport for a long-running deployment (see Deploy an MCP Server to Cloudflare Workers for a hosted alternative), keeping stdio for local use.
  • Build multi-arch images (docker buildx build --platform linux/amd64,linux/arm64) so the same tag runs on Apple Silicon and x86 hosts.