Deploy an MCP server with Docker.
Updated September 12, 2026
"Deploy an MCP server with Docker" is two jobs. One is packaging a
local stdio server so a desktop client can
docker run -i. The other is running a
Streamable HTTP server as a container behind a URL. Mixing them is the
usual failure: a published port on a process that only reads stdin, or a
URL client pointed at an image that never opened a socket.
https://mcp.dialmcp.com/mcp over
Streamable HTTP with OAuth 2.1. This page is
for operators shipping their own MCP server. Need phone calls
from an agent instead? Install DialMCP.
Pick the transport before the Dockerfile
-
stdio: the host launches your process and speaks
JSON-RPC on stdin/stdout. No port. This is Claude Desktop's local
command/argsshape. -
Streamable HTTP: the process is an HTTP server. The
client POSTs each JSON-RPC message to one MCP endpoint, usually
/mcp. This is the current remote transport in the 2026-07-28 spec.
HTTP+SSE from protocol version 2024-11-05 is deprecated. Do not put
-t sse in a new image. The category explainer
is remote MCP servers; the one-file
walk from stdio to a URL is the
MCP server tutorial.
Shape 1: a stdio image (local)
The container is the server process. The client must keep
stdin open. Docker’s own MCP packaging notes (January 2025) use
docker run -i --rm and recommend
multi-platform images (linux/amd64 and
linux/arm64) because desktop clients run on
both.
{
"mcpServers": {
"notes": {
"command": "docker",
"args": ["run", "-i", "--rm", "--pull=always", "your-account/notes-mcp"]
}
}
}
That block belongs in a local config file (Claude Desktop’s
claude_desktop_config.json, Cursor’s
mcp.json), not in VS Code’s
servers key without
"type": "stdio". File shapes:
MCP server configuration examples.
- Do not
EXPOSEa port. Nothing should listen. - Do not log to stdout. Stdout is the wire; logs go to stderr.
- Pass secrets as env vars or files. A stdio server is not an OAuth resource server.
Shape 2: a Streamable HTTP image (remote)
The container listens. The official Python SDK’s
mcp.run(transport="streamable-http") serves
a Starlette app with uvicorn. Defaults are
127.0.0.1:8000 and path
/mcp. Inside Docker,
127.0.0.1 is unreachable from the host, so
bind 0.0.0.0. Source:
Python SDK: running your server
(checked September 12, 2026).
For anything you actually deploy, hand
streamable_http_app() to uvicorn rather than
calling mcp.run() as PID 1:
from mcp.server import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
mcp = MCPServer("notes")
@mcp.tool()
def ping() -> str:
"""Tiny tool so you can see the server answer."""
return "ok"
security = TransportSecuritySettings(
allowed_hosts=["mcp.example.com", "mcp.example.com:*"],
allowed_origins=["https://app.example.com"],
)
app = mcp.streamable_http_app(transport_security=security)
FROM python:3.12-slim
WORKDIR /app
COPY server.py .
RUN pip install --no-cache-dir "mcp[cli]" uvicorn
EXPOSE 8000
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
docker build -t notes-mcp:http .
docker run --rm -p 8000:8000 notes-mcp:http
# Client URL while you are still on the laptop:
# http://127.0.0.1:8000/mcp
The 421 that looks like a dead container
Out of the box the SDK only accepts
localhost / 127.0.0.1
Host headers. That is DNS-rebinding protection, and it is correct on your
laptop. Behind a real hostname every request comes back
421 Misdirected Request /
“Invalid Host header” until you pass
transport_security=. The 421 is plain HTTP,
not JSON-RPC, so the MCP client reports a generic transport error. Source:
Python SDK: deploy and scale
(checked September 12, 2026).
- Allowlist the hostname you actually serve, including the
host:*form if a port appears onHost. - Behind a reverse proxy that already controls
Host,enable_dns_rebinding_protection=Falseis the honest setting. - Passing
host="mcp.example.com"torun()does not allowlist that name. Settransport_securityexplicitly.
Reverse proxy, TLS, Origin
The spec requires Origin validation on Streamable HTTP to stop DNS
rebinding. An invalid Origin is HTTP 403. Terminate TLS at the proxy;
clients expect https://…/mcp. If the server
returns an SSE body for a long tool call, send
X-Accel-Buffering: no so nginx does not hold
the stream. Keep-alives on long
subscriptions/listen streams are SSE comment
lines, not a second GET endpoint (GET was removed in 2026-07-28).
Auth
Authorization is optional in the spec and expected the moment the URL is public and the tools touch user data. HTTP MCP servers follow the OAuth 2.1 framework MCP added in 2025-03-26. DialMCP’s hosted path is documented on OAuth and phone verification: browser sign-in, then SMS of a US or Canadian mobile number, no API key. If you are not ready to be an OAuth resource server, do not publish the container.
Workers on 2026-07-28
A modern request is one self-contained POST. There is no
Mcp-Session-Id and nothing for a load
balancer to stick on. Scale with
uvicorn server:app --workers 4 (or your
platform’s process manager). Sticky sessions are a legacy-client
problem. If you use multi-round-trip tools, share the
requestState key across workers; the SDK
default is os.urandom(32) per process.
What not to containerize
- DialMCP. The calling service is hosted and closed-source. The dialmcp-connector npx package is a stdio bridge to that host, not a second phone-call server. Wrapping the bridge in Docker does not give you on-prem calling.
- A localhost URL as a Claude custom connector. Anthropic reaches the server from its cloud. A container bound to your laptop will not answer. Same fact as on configuration.
- SSE “for compatibility” as the only listener. New work should be Streamable HTTP. Keep a legacy SSE path only if you still have 2024-11-05 clients.
Go-live checklist
- Transport chosen: stdio image or Streamable HTTP image, not both in one ENTRYPOINT.
- HTTP image binds
0.0.0.0and publishes one port. - MCP path is
/mcpunless you changed it on purpose. transport_securityallowlists the real hostname (or the proxy owns Host).- TLS at the edge. Origin checks on.
- OAuth (or another real auth) before the URL is public.
- Health check on a separate unauthenticated route if the orchestrator needs one. The SDK does not ship one.
- Client config uses the HTTP shape, not a
commandblock. Cheat sheet: configuration examples.
When to stop building
If the job is “give this agent a phone,” do not start a Dockerfile. Connect the hosted server with the snippets on install and client setup. If the job is “ship my own tools,” Docker is the packaging step after the build walkthrough works on localhost. Back to the docs hub.