MCPサーバーをDockerで動かす方法|stdioでハマりやすいポイント

MCPサーバーをDockerで動かす方法|stdioでハマりやすいポイント AI開発

MCPサーバーをDocker化すると、PythonやNode.jsなどの実行環境をホストPCへ直接インストールせずに済みます。依存パッケージもImageへ固定できるため、「自分のPCでは動くが別のPCでは動かない」という環境差も減らせます。

しかしstdio TransportのMCPサーバーをDockerで動かす場合は、通常のWebアプリとは違うポイントがあります。特に多いのが「Containerは起動しているのにMCP Clientから接続できない」というトラブルです。

stdioでは、HostがMCP Serverを子プロセスとして起動し、ServerのstdinへJSON-RPC Requestを書き込み、stdoutからResponseを読み取ります。Portへ接続しているわけではありません。Docker化すると、この通信経路の途中にdocker runが入ります。

Docker化したstdioの通信経路
MCP Host
  ↓
docker run(プロセス)
  ↓ stdin
Container内のMCP Server
  ↓ stdout
docker run(プロセス)
  ↓
MCP Host

そのため、Docker側でstdin/stdoutを正しく接続しないと、MCP Server本体が正常でも通信できません。この記事では、stdio MCP ServerをDockerで実行する基本構成と、-i・TTY・stdoutのログ・Volume・Working Directory・Windowsの設定など、ハマりやすいポイントを解説します。

stdioとStreamable HTTPのどちらを選ぶかはMCPのstdioとStreamable HTTPの違い|ローカル・リモート構成の選び方で解説しています。

スポンサーリンク

stdio MCPはContainerを常駐Webサーバーとして動かすわけではない

Webサーバーなら、docker run -p 8000:8000 my-serverのようにPortをPublishして、HTTP Clientから接続します。stdio MCPではPortを使いません。MCP Host自身がdocker run ...を実行し、そのプロセスのstdinとstdoutをMCP Transportとして使います。

つまりDockerは「MCP Serverをネットワーク上へ公開するもの」ではなく、「MCP Server Processを隔離された環境で起動するもの」として使います。複数のClientから共有したいなら、Streamable HTTPでServerを公開する構成になります(公開時の認証やHTTPSはMCPサーバーをローカルではなくリモート公開する方法|認証・HTTPSの基本で解説しています)。

Pythonでstdio MCPサーバーを作る

まず単純なMCP Serverを用意します。

server.py
from mcp.server import MCPServer

mcp = MCPServer("docker-example")


@mcp.tool()
def add(a: int, b: int) -> int:
    """2つの整数を加算します。"""
    return a + b


if __name__ == "__main__":
    mcp.run()

mcp.run()にTransportを指定しなければ、stdioで起動します。このファイルをTerminalから直接python server.pyで実行すると、何も表示されずProcessが止まっているように見えます。これは正常です。ServerはstdinからMCP Messageが送られてくるのを待っています。

Dockerfileを作ってBuildする

まず依存を書いたrequirements.txtを用意します。MCP SDKのPackage名はmcpです。

requirements.txt
mcp

このrequirements.txtとserver.pyを使って、次のDockerfileを作ります。

Dockerfile
FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY server.py .

ENTRYPOINT ["python", "/app/server.py"]

requirements.txtには利用するMCP SDKを書きます。重要なのは最後のENTRYPOINTです。Docker公式ドキュメントでは、ENTRYPOINTはExec Form(配列形式)が推奨されています。Shell Formだと/bin/sh -cの子Processとして起動するため、Signalが渡されず、docker stopのSIGTERMをApplicationが受け取れません。

Buildは通常のDockerと同じです。

Terminal
docker build -t my-mcp-server .

問題になりやすいのは実行時です。

stdioではdocker runに-iが必要

Terminal
docker run --rm -i my-mcp-server

-i(--interactive)はContainerのstdinをOpenに保ち、標準入力からContainer ProcessへDataを送れるようにします。MCP stdioではHostからのRequestがstdinへ流れるため、-iがないとServerにRequestが届きません。「起動直後に切断される」「Serverがすぐ終了する」「MCP Clientが接続できない」という場合は、まず-iが付いているか確認してください。

stdio Serverはstdinの寿命と結び付いています。MCP仕様では、Clientはまず子Processへの入力Streamを閉じてServerの終了を待ち、終了しなければ強制終了する、という手順でShutdownすることが推奨されています。Serverも、stdinが閉じられたら速やかに終了することが推奨されています(SHOULD)。Docker経由でも同じで、stdinが閉じればContainer内のServerが終了するのが自然な流れです。これは異常終了ではなく、stdioの通常のLifecycleです。

-tは基本的に付けない

Dockerに慣れているとdocker run -itを反射的に使いたくなりますが、MCP stdioでは-iだけを使うほうが安全です。-tはPseudo-TTYを割り当てるOptionで、人間が操作するTerminal向けの機能です。

MCP stdioが必要としているのは、rawなstdin、rawなstdout、stderrというPipeです。さらにPseudo-TTYを使うと、stderrとstdoutが1つのStreamにまとまり、別々に扱えなくなることが知られています。-tを外せば、2つのStreamは分離されたままになります。

MCP仕様では、Serverはstdoutに有効なMCP Message以外を書いてはならず(MUST NOT)、ログはstderrへ書いてよいとされています。stderrがstdoutと混ざる構成は、この前提を崩します。基本はdocker run --rm -iです。

stdoutにログを出すとMCP通信が壊れる

stdio Transportではstdout自体がProtocolの通信路です。起動時にprint("MCP Server started")とすると、JSON-RPC以外の文字列がstdoutへ混ざり、Hostが接続を切ることがあります。Loggingにはstderrを使います。

OK: loggingを使う
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

logger.info("MCP Server started")

Python SDKの公式トラブルシューティングでも、SDKは実行中の迷子のstdoutをstderrへ逃がす一方で、Wrapper ScriptのechoやImport時のprint()のように、それより前に出た出力はProtocol Streamへ混ざり、1行の余計な出力でHostが接続を切る場合があると説明されています。仕組みと原因の切り分けはMCPのstdioサーバーが接続できない原因|stdoutにログを出してはいけない理由で詳しく解説しています。

DockerではEntrypoint ScriptやWrapper Scriptもstdoutを汚す

Docker化では、Server本体だけでなくDocker側のScriptも対象になります。Entrypoint Scriptのechoはstdoutへ出るため、デバッグ出力は>&2でstderrへ出します。最後はexecでServerへ置き換えます。

entrypoint.sh
#!/bin/sh
set -e

echo "Starting MCP server..." >&2

exec python /app/server.py

このScriptをENTRYPOINTにするには、DockerfileでCOPYして実行権限を付けます。

Dockerfile(entrypoint.shを使う場合)
FROM python:3.13-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY server.py entrypoint.sh ./

RUN chmod +x /app/entrypoint.sh

ENTRYPOINT ["/app/entrypoint.sh"]

Docker公式は、Shell FormのENTRYPOINTを使う場合はexecから始めるよう案内しています。Scriptの最後をexec python ...にするのも同じ考え方で、ShellがServerに置き換わり、ServerがMain ProcessとしてSIGTERMを受け取れます。

Hostからdocker runの前後にWrapper Scriptを挟む場合も同じです。echo "Launching Docker..."はstdoutへ出てProtocolを壊すので、>&2にして、最後はexec docker run --rm -i my-mcp-serverとします。また、docker buildをHostが実行するCommandの中に含めるとBuild Logがstdioへ混ざる可能性があるため、Imageは事前にBuildしておきます。

MCP Hostからdocker runをCommandとして登録する

Hostが実行するCommandをpython server.pyからdocker run --rm -i my-mcp-serverへ置き換えます。commandにdocker、argsに残りを配列で渡します。

MCP設定(JSON)
{
  "mcpServers": {
    "docker-example": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "my-mcp-server"
      ]
    }
  }
}

Claude Codeでコマンドから追加するなら、Claude Code自身のOptionとServerのCommandを--で区切ります。--より後ろはそのままServerへ渡されます。

Terminal
claude mcp add --transport stdio docker-example -- docker run --rm -i my-mcp-server

Hostから見ると、直接Pythonを起動するかDocker CLIを起動するかが違うだけです。dockerのstdin/stdoutがContainer Processへつながるので、その先にMCP Serverがあります。

-dや常駐Containerではstdioにならない

docker run -dはContainerをDetached Modeで起動してdocker run自体は終了します。MCP Hostが必要としているのは「docker runのstdin/stdoutがContainer内のServerへ直結している状態」なので、Detachedにするとそのstdio Connectionを失います。stdioではdocker run --rm -iをForegroundのProcessとして使います。

--rmは終了時にContainerを自動削除します。HostがConnectionごとにContainerを起動するstdioでは、停止済みContainerが溜まらないので相性がよい指定です。デバッグ中だけ--rmを外してdocker logsでstderrを確認する方法もあります。

Containerを常駐させたい、複数Clientから使いたい、Load Balancerの背後に置きたいといった要件が出てきたら、stdioではなくStreamable HTTPを検討します。Python SDKのドキュメントでも、stdioはLocal Server向けのDefault、Streamable HTTPはDeployするもの向けという位置付けです。

Docker Composeではstdin_openを使う

Composeからstdio Serverを起動するなら、stdin_open: trueを指定します。Compose公式では、stdin_openはdocker run -iと同じ、ttyは-tと同じ意味とされています。ttyはstdioには不要なので付けません。

compose.yaml
services:
  mcp:
    build: .
    stdin_open: true

ただし常駐Serviceとしてdocker compose up -dするよりも、Hostから必要なときだけ起動するほうがstdioには自然です。docker compose runはService定義からOne-offのContainerを実行でき、--interactiveはデフォルトで有効、Pseudo-TTYは-Tで無効にできます。

Terminal
docker compose run --rm -T mcp

単一のServerなら、Composeを介さずHostがdocker run --rm -iを直接実行するほうが単純な場合も多いでしょう。

ファイルを扱うMCPはBind Mountが必要

ContainerはHostのFilesystemを自動では見られません。Project Directoryを読むToolがあっても、MountしていなければContainerからは存在しません。そこでBind Mountを使います。Docker公式では、Bind Mountに--mountと-vのどちらも使えますが、--mountのほうが明示的で推奨されています。

Terminal
docker run \
  --rm \
  -i \
  --mount type=bind,src=/home/user/project,dst=/workspace \
  my-mcp-server

Container内から見えるPathは/workspaceです。MCP Server内部ではHost側の/home/user/projectではなく/workspaceを扱います。

Bind MountのDefaultはRead-writeで、Container ProcessはMountしたHost Fileを変更・削除できます。読むだけで十分ならreadonly(またはro)を付けます。

Terminal
docker run \
  --rm \
  -i \
  --mount type=bind,src=/home/user/project,dst=/workspace,readonly \
  my-mcp-server

Server側のPath Validationと組み合わせれば二重防御になります。Path Traversalの対策はMCPサーバーからファイルを安全に読み込む方法|パストラバーサル対策で解説しています。Bind Mount自体の仕組みや、Volumeとの使い分けは【Docker】volume完全ガイドと【Docker】bind mount vs volume完全比較ガイドが参考になります。

MountでImage内のファイルを隠さない

Docker公式は、既存のFileがあるContainer内のDirectoryへBind Mountすると、元のFileがMountによって隠されると説明しています。たとえばImageの/appにServer本体と依存ライブラリがあるのに、dst=/appへHostのDirectoryをMountすると、Build時にInstallしたものが消えたように見えます。

MCP Server本体はImageの/appに置き、AIに扱わせるProjectだけを/workspaceへMountする、と分けると安全です。

役割分担
/app        → MCP Server本体(Imageに含める)
/workspace  → AIに扱わせるProject(Host側をMount)

Working DirectoryはHostとContainerで別物

Hostでは/home/user/projectやC:\Users\user\projectだったものが、Containerでは/workspaceになります。ToolがRelative PathをCurrent Directory基準で解釈するなら、DockerfileのWORKDIR /workspaceか、docker runの--workdir /workspaceで基準を明示します。--workdirはImageのWorking Directoryを上書きでき、指定したPathがなければContainer内に作られます。

Terminal
docker run \
  --rm \
  -i \
  --workdir /workspace \
  --mount type=bind,src=/home/user/project,dst=/workspace \
  my-mcp-server

Relative Pathの基準がHostなのかContainerなのかを、Toolの仕様として決めておいてください。

MCP設定ではShellの展開を期待しない

Terminalならsrc="$(pwd)"と書けますが、MCP設定JSONのargsに$(pwd)を書いても展開されない場合があります。Hostはcommandとargsを、Shell Command Lineではなく実行ファイルと引数の配列として直接起動するのが一般的だからです。確実なのは絶対Pathを書くことです。

MCP設定(JSON)
{
  "mcpServers": {
    "docker-example": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "--mount",
        "type=bind,src=/absolute/path/to/project,dst=/workspace,readonly",
        "my-mcp-server"
      ]
    }
  }
}

Claude Codeの.mcp.jsonなら、${VAR}と${VAR:-default}という環境変数の展開がcommand・args・envなどで使えます。チーム共有のProject設定にユーザー固有のPathを直接書かない場合に便利です。Shellの$(pwd)とは別の機能である点に注意してください。

WindowsではHost PathとContainer Pathを混同しない

Windows + Docker Desktopでも考え方は同じで、Host側のC:\Users\user\projectをLinux Containerの/workspaceへMountします。MCP Server内部で扱うのは/workspace/src/index.tsであり、Host PathのC:\Users\...をそのままToolの引数にしても、Linux Container内には存在しません。

Hostのファイル操作ToolをDocker化するときは、Host Path → Mount → Container Path → MCP Toolという変換を意識します。Windowsでstdio Serverが起動しない原因の切り分けはMCPサーバーが起動しない原因|Windowsのパス・環境変数・stdioを確認も参考になります。

Container内のUser権限とSecretの渡し方

Volumeを正しくMountしてもPermission deniedになるのは、Container内のProcess UserがMount先のFileを読み書きできないためです。ただし何でもRootで動かして解決するのはおすすめできません。ファイルを更新するToolを持つなら、ContainerがHostのどこまで変更できるかが、そのままMCP ToolのCapabilityになります。読み取り専用ならread-only Mount、書き込みが必要なら対象Directoryだけをread-writeにします。

API Keyが必要な場合は、COPY .env /app/.envでImageへ焼き込まず、実行時に環境変数で渡します。-e API_KEYとすれば、HostのEnvironment Variableの値をContainerへ引き渡せます。MCP設定JSONへSecretを直接書くとその設定ファイル自体がSecretになるため、扱いに注意します。

Terminal
docker run --rm -i -e API_KEY my-mcp-server

HostのPython環境はContainerでは使われない

Hostでは動いていたのにContainer内でModuleNotFoundErrorになるのは、Dockerfileで依存をInstallしていないためです。Docker化した時点で、HostのVirtual Environmentは使われません。requirements.txtやpyproject.tomlから、Image Build時にContainer内へInstallします。

Node.jsでも同様で、Windows HostのBinaryを含むnode_modulesをLinux Containerへそのまま持ち込むと動かないことがあります。依存はImage Build時にContainer内でInstallし、Bind Mountでnode_modulesをHostから上書きしないようにします。

–read-onlyとread-only Mountで被害範囲を絞る

Fileを読むだけのMCP Serverなら、Container自身のRoot Filesystemも--read-onlyにして、必要なDirectoryだけを書き込み可能にできます。

Terminal
docker run \
  --rm \
  -i \
  --read-only \
  --mount type=bind,src=/home/user/project,dst=/workspace,readonly \
  my-mcp-server

AI Agentへローカル環境のすべてを直接見せるより、Containerと限定したBind Mountを介したほうが、アクセス範囲を明確にできる場合があります。Serverが一時Fileなどを書き込む場合は、--read-onlyで動かなくなることがあります。その場合は必要なDirectoryだけを書き込み可能にします。

接続できないときの切り分け手順

MCP HostにはServer disconnected程度しか表示されないことがあります。その場合は同じCommandをTerminalで実行して、Containerがすぐ終了するかを確認します。何も表示されずstdin待ちになるのは正常です。

Terminal
docker run --rm -i my-mcp-server

すぐ終了するなら、Image内のServerがCrashしている可能性があります。--rmを外して名前を付けて起動し、Containerが残っている状態でLogを確認します。

Terminal
docker run -i --name mcp-debug my-mcp-server
docker logs mcp-debug

Debug LogはMCP Protocol用のstdoutではなく、stderrやDocker Logから調べます。確認する順序は次のとおりです。

  • stdinが開いているか(-iがあるか)
  • stdoutが汚れていないか(Entrypoint/Wrapper Scriptのecho、print())
  • TTYを付けていないか(-t)
  • ENTRYPOINTが正しいか、Exec Formになっているか
  • Container内に依存ライブラリがあるか
  • Mount先とWorking Directoryが正しいか

MCPサーバーのDocker化に関するよくある質問

Qdocker run -pでPortを公開しないと接続できませんか?

Astdioでは不要です。HostがDocker CLIを子プロセスとして起動し、そのstdin/stdoutで通信するためPortを使いません。Portの公開が必要なのは、Streamable HTTPでServerをネットワークへ公開する場合です。

QDocker Desktopがなくても、同じ構成で動きますか?

ADocker Desktopは必須ではありません。Host側のCommandとしてdockerが実行できれば、この記事の構成は成り立ちます。ただしWindowsやmacOSではLinux Containerを動かす仕組みが必要なため、Docker DesktopなどをInstallするのが一般的です。

QMCP設定JSONの$(pwd)が展開されないのはなぜですか?

AHostがcommandとargsをShellを介さず直接起動するのが一般的だからです。絶対Pathを書くか、Claude Codeの.mcp.jsonであれば${VAR}形式の環境変数展開を使います。Shellの$(pwd)とは別の機能です。

QMCP Hostが実行するCommandにdocker buildも含めてよいですか?

Aおすすめしません。Build時のLogがstdioへ混ざってProtocolを壊す可能性があるためです。Imageは事前にBuildしておき、Hostはdocker run --rm -iだけを実行します。

Q--mountと-vはどちらを使うべきですか?

ADocker公式では--mountのほうが明示的で推奨されています。readonlyなどのOptionも指定しやすいため、この記事では--mountを使っています。

stdio MCPではdocker runを含む1本のPipe全体がTransportになる

stdio MCP ServerをDockerで動かす基本構成は、DockerfileでENTRYPOINT ["python", "/app/server.py"]のようにExec Formで指定し、Hostからはdocker run --rm -i my-mcp-serverをcommandとargsで起動するだけです。

最も重要なのは、-iは付けるが、stdio Protocolに不要な-tは基本的に付けないことです。Server本体だけでなく、Entrypoint ScriptやWrapper Scriptもstdoutへ通常Logを出さないようにします。

Fileを扱う場合はHost PathとContainer Pathを分けて考え、必要なDirectoryだけBind Mountします。読み取りだけならreadonlyにし、Bind MountがDefaultではHost Fileを書き換えられる点も意識します。接続できないときは、MCP Protocolを疑う前に、stdin・stdout・TTY・ENTRYPOINT・依存・MountとWorking Directoryという、Dockerとstdioの境界を確認すると原因を見つけやすくなります。

stdio MCPでは、Docker ContainerそのものがServer Endpointなのではなく、docker runを含めた1本のstdin/stdout Pipe全体がMCP Transportになります。この構造を理解しておけば、Docker化しても通常のLocal MCP Serverと同じ感覚で運用できます。