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--checksmoke command. - A multi-stage
Dockerfilethat builds withuvand ships a non-rootpython:3.12-slimruntime. - A smoke script that launches the image with
docker runand speaks MCP to it over stdio. - A
.mcp.jsonthat registers the container with a client, plus aMakefile.
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 --packagecreates thesrc/mcp_docker_demo/layout, the[project.scripts]console script, and theuv_buildbackend. 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 usespython:3.12-slim).- The lockfile
uv.lock(written byuv 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. runpicks the transport fromMCP_TRANSPORT. The defaultstdiopath is what a client launches withdocker run -i. The HTTP branch binds0.0.0.0(not127.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
mainis the[project.scripts]target, so the image entrypoint (mcp-docker-demo) calls it. With no flag it starts the server; with--checkit lists tools and exits 0.--checkis the image smoke test. Runningdocker run --rm <image> --checkproves 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:1enables theRUN --mount=type=cachebuild 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 ispython:3.12-slim-bookwormwith theuvbinary 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
ENVflags tune uv for images.UV_COMPILE_BYTECODE=1precompiles.pycfor faster startup;UV_LINK_MODE=copycopies packages into the venv instead of hardlinking to the cache (the cache is a separate mount);UV_PYTHON_DOWNLOADS=0forbids downloading a standalone interpreter, so uv uses the image’s python. - Dependencies install before the project.
COPY pyproject.toml uv.lockthenuv sync --no-install-projectbuilds the dependency layer, which Docker caches and reuses until the lockfile changes — only your own code re-installs on a typical edit.--no-devomits test dependencies from the image. - The runtime stage copies just
/app(source plus.venv) from the builder, with nouvand no build cache.ENV PATH=/app/.venv/bin:$PATHputs the console script onPATH. USER appdrops 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 todocker runafter 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 idoverrides the entrypoint to runid, proving the container’s default user isapp(uid 999), not root.docker run <image> --checkappends--checkto 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
uvtoolchain 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 spawnsdocker run, and the container’s stdin/stdout carry JSON-RPC. This is the exact command a client will launch.-iis required. It keeps the container’s stdin open; without it the stdio transport has no channel and the handshake fails immediately.--rmremoves 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 answeredgreetandadd(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
commandisdockerand 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. ✔ Connectedmeans Claude Code ran the container and completed the MCP handshake. Remove it withclaude mcp remove mcp-docker-demo -s local. The committed.mcp.jsonshows 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, andrundepend onimage, so the container a target launches is always freshly built.smokeruns the stdio round-trip against the image;checkruns the fast in-container list-and-exit..DEFAULT_GOAL := helpprints the target list on baremake. Recipes are tab-indented.
Troubleshooting
Cannot connect to the Docker daemon. Start Docker Desktop and wait for it to report running, then retrydocker version.- The client connects but sees no tools, or the handshake hangs. The run
command must include
-iso 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
.dockerignoreexcludes.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 onPATHin the runtime stage. ConfirmENV 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(theuvimage andpython:3.12-slim-bookwormshare/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
uvin 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 anddocker run <image> --checksmoke-tests it. - Clients launch the container over stdio with
docker run -i --rm, the same command that goes in.mcp.jsonandclaude mcp add. .dockerignorekeeps the context clean so the host venv never ships, and a--checkplus 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.