MCP Python SDKのstdio接続を手元で試していて、親に置いたダミーのトークンが子では消えた。同じPythonを普通のsubprocessで起動すると見えている。
MCPサーバーへ渡す環境変数は、名前を決めて明示する。自分はこの方針で組みたい。親の環境を丸ごと渡すと、使わない秘密情報まで渡してしまうんだよね。
認証先への通信やLLMの呼び出しは使わず、ローカルの子へ環境変数が届くかを切り出した。
親で見えても、stdioの子には届かない
macOS、CPython 3.12.13、mcp==1.27.1で実行した。依存はanyio==4.13.0、pydantic==2.13.4。対象はmcp.client.stdio.stdio_clientだ。
親へダミー変数を2個置く。MCP_DEMO_TOKENが必要な設定、MCP_DEMO_OTHERが不要な設定だ。実際のAPIキーは使わない。
env_probe.pyに保存し、上記の環境でpython env_probe.pyを実行する。
import asyncio
import json
import os
import subprocess
import sys
from mcp import StdioServerParameters
from mcp.client.stdio import stdio_client
os.environ["MCP_DEMO_TOKEN"] = "dummy"
os.environ["MCP_DEMO_OTHER"] = "unrelated"
child = '''import json, os
params = {"token": "MCP_DEMO_TOKEN" in os.environ,
"other": "MCP_DEMO_OTHER" in os.environ,
"path": "PATH" in os.environ}
print(json.dumps({"jsonrpc": "2.0", "method": "probe", "params": params}), flush=True)
'''
def show(name, values):
print(name, " ".join(f"{k}={v}" for k, v in values.items()))
async def main():
raw = subprocess.check_output([sys.executable, "-c", child], text=True)
show("subprocess", json.loads(raw)["params"])
cases = [
("SDK default", None),
("SDK empty", {}),
("SDK explicit", {"MCP_DEMO_TOKEN": os.environ["MCP_DEMO_TOKEN"]}),
("SDK all", dict(os.environ)),
]
for name, env in cases:
server = StdioServerParameters(
command=sys.executable, args=["-c", child], env=env
)
async with stdio_client(server) as (read, _):
message = await read.receive()
if isinstance(message, Exception):
raise message
show(name, message.message.root.params)
asyncio.run(main())
子は環境変数の有無をJSON-RPC形式で1行返す。プロセス起動と読み取りの検証用で、MCPの初期化やツール呼び出しは省いている。トークンの値は出力しない。
手元の出力は次のとおり。
subprocess token=True other=True path=True
SDK default token=False other=False path=True
SDK empty token=False other=False path=True
SDK explicit token=True other=False path=True
SDK all token=True other=True path=True
通常のsubprocessでは2個とも届いた。SDKでenvを省略すると、どちらも届かない。
最初に気になったのは、その次のpath=Trueだった。
env={}でも空の環境にはならない
SDKのenvへ空の辞書を渡しても、PATHは残った。
インストール済みの1.27.1で、mcp/client/stdio/__init__.pyを読んだ。同バージョンのソースでは、stdio_client()がget_default_environment()の辞書へserver.envを足して、子を起動している。
このmacOS環境で既定の引き継ぎ対象に入っていた名前は、HOME、LOGNAME、PATH、SHELL、TERM、USER。親に値がある変数だけを拾い、値が()で始まるものは除く。Windows向けの一覧は別にある。
MCP_DEMO_TOKENは一覧にないので、親で設定しただけでは拾われない。env={}も、既定の辞書へ何も追加しない指定になる。今回の3通りを並べるとこうだ。
| SDKに渡すenv | 子へ渡す辞書の作り方 |
|---|---|
| 省略またはNone | SDKが選んだ既定の変数 |
| 空の辞書 | 既定の変数をそのまま使う |
| 名前と値を指定 | 既定の変数へ追加し、同名なら上書き |
SDKのenvで、既定の変数を一括削除する指定はできない。標準ライブラリへ直接渡すenvと混同しやすいところだ。
コードのsys.executableは同じPythonを使うために固定した。pythonという名前だけだと、どの仮想環境を見ていたか調べる手間も増える。
全部渡せば直る。そのとき何が増えるか
SDK allでは不要な変数も届いた。dict(os.environ)で接続エラーが解消しても、渡す範囲は絞れていない。
自分なら起動設定に、必要な変数名を列挙する。SDK explicitがその形だ。実際の名前でも、親からはos.environ[名前]で読む。設定が欠けたら、子を起動する前に止まる。get()でNoneを渡して別のエラーになるより、設定漏れを追いやすい。
この制限だけでは子プロセスを隔離できない。同じOSユーザーの権限で動き、読めるファイルから別の情報を取得し得る。other=Falseは、この起動時にダミー変数を環境経由で渡さなかったことだけを示す。外部製サーバーのファイル権限や実行環境の分離は、別に決める必要がある。
確認したのはローカルの起動まで。各AIアプリの設定処理やHTTP接続、実サービスの認証は対象外だ。
MCPサーバーで設定が見つからないときは、親のシェルで見えるかに加えて、SDKが子へ渡す名前を確認する。必要な変数を1個ずつ明示すれば、今回の例ではトークンを渡しつつ、不要な変数は渡さずに済んだ。起動確認には値を出さない小さなプローブを使える。接続を直す変更と、サーバーへ渡す情報を増やす変更を、同じ行で見直せるようにしておきたい。