プラグインに入れた MCP サーバーを呼ぶと、毎回「認証に失敗しました」と返ってきます。トークンは設定してあり、同じサーバーを Claude Code から直接つなぐと通ります。失敗するのは Cowork から呼んだときだけ、という状況を想像してみてください。
こういう症状は、鍵が間違っているのか、サーバーが落ちているのか、権限なのか、疑う先が多すぎて胃が痛くなります。Issue #94395 を読んでいて、私は手が止まりました。報告されていたのは、そのどれでもない四つ目の原因だったからです。
最初にお伝えしたいのは、Cowork では、プラグイン同梱の MCP 設定に書いた ${VAR} が、変数名によっては展開されないまま「文字列そのもの」としてサーバーに渡されるという報告があることです。ここでは、その報告の中身と、自分の環境で当てはまるかを短時間で確かめる手順を書き残します。
先に断っておくこと
この内容は、公開されている Issue の報告に基づいています。2026 年 11 月 2 日の時点で Issue は open のままで、公式からの回答コメントは付いていません。報告者の環境は Windows 11 の Cowork です。私自身が Cowork 上で同じ現象を再現したわけではありません。
私が手元で動かしたのは、この記事の後半に載せる洗い出しスクリプトだけです。サンプルの設定ファイルに対して、期待どおりの出力が出ることまでは確かめました。再現の確認と、直し方の正解は、まだ誰も持っていない段階だと考えてください。
報告されている症状を一文にする
報告者は、プラグインの .mcp.json に次のような env を書いていました。
{
"mcpServers": {
"testmo": {
"command": "npx",
"args": ["-y", "@example/testmo-mcp-server@latest"],
"env": {
"TESTMO_API_TOKEN": "${TESTMO_API_TOKEN}",
"TESTMO_BASE_URL": "${TESTMO_BASE_URL}"
}
}
}
}公式ドキュメントの「Environment variable expansion in .mcp.json」の節には、${VAR} の形で環境変数を設定ファイルに書ける旨があります。ところが報告によると、Cowork ではこの値が展開されず、サーバー側には ${TESTMO_API_TOKEN} という文字列がそのまま届くとのことです。
変数を OS の環境変数で渡しても、~/.claude/settings.json の env に書いても、アプリを再起動しても結果は変わらなかったと書かれています。つまり「変数が未設定だから空になった」のではありません。
手掛かりはログの一行です
報告者がたどり着いた決め手は、アプリのログに出る警告でした。
[warn] [PluginMcpHostConfig] Plugin "..." server "testmo": config references environment
variables outside the MCP stdio safelist: TESTMO_API_TOKEN, TESTMO_BASE_URL — left unexpanded
(the desktop host does not expose arbitrary host env to plugin servers)この警告は、プラグインを読み込んだ直後に出ていて、サーバーを起動する前の段階です。報告者は、変数が実際に設定されているかどうかに関係なく、同じ行が同じ内容で出たと述べています。変数の中身ではなく、変数の名前だけを見て弾く仕組みのようだ、というのが報告者の読みです。
展開されると報告されている名前は、組み込みの ${CLAUDE_PROJECT_DIR}、${CLAUDE_PLUGIN_DATA}、${CLAUDE_PLUGIN_ROOT} の三つでした。それ以外は、ドキュメントに載っている ${API_KEY} のような例でも展開されなかった、とあります。
ここで私が大事だと思うのは、画面には何のエラーも出ないという点です。認証が通らないという結果だけが見えて、原因はログの warn 行にしか残りません。症状から原因まで辿れる人が少ないのは、このためだと感じています。
自分の環境で当てはまるかを三つの順で確かめます
疑う順番を決めておくと、胃の痛みはかなり軽くなります。私は次の順で見ます。
- ログに
outside the MCP stdio safelistが出ているかを検索します。 - 出ていれば、そのプラグインの設定に、組み込み以外の
${…}があるかを数えます。 - あれば、その値を設定側に頼らず、サーバー自身に読ませる形へ寄せられるかを考えます。
一つ目は、ログフォルダ全体を対象に検索するだけです。報告者の Windows では %APPDATA%\Claude\logs\main.log に出ていました。macOS など別の OS でのログの場所は、私は確認できておりません。お使いの環境のログフォルダを探して、同じ文字列で検索してみてください。
# LOG_DIR は自分の環境の Claude のログフォルダに置き換えます
grep -rn "outside the MCP stdio safelist" "$LOG_DIR"一行も出なければ、この記事の症状とは別の原因です。鍵の貼り間違いやサーバー側の障害など、別の方向を先に見てください。
配布前に洗い出すスクリプト
二つ目の「組み込み以外の ${…} を数える」作業は、手で目視するとかならず見落とします。そこで、.mcp.json と plugin.json の中の参照を拾って、組み込みかどうかを判定する小さなスクリプトを書きました。
全部を警告にしてしまうと、組み込みの名前まで毎回赤く出て、読む気が失せます。そこで、組み込みは OK、それ以外は RISK と分けています。
#!/usr/bin/env python3
"""mcp_env_preflight.py — プラグイン同梱 MCP 設定の ${VAR} を洗い出す
usage: python3 mcp_env_preflight.py ./my-plugin
"""
import json, re, sys
from pathlib import Path
# issue #94395 で「展開される」と報告された組み込みの名前。増減は自分のログで確かめる
BUILTIN = {"CLAUDE_PROJECT_DIR", "CLAUDE_PLUGIN_DATA", "CLAUDE_PLUGIN_ROOT"}
REF = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}")
def servers_of(path: Path):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
print(f"READ-FAIL {path}: {e}")
return {}
# .mcp.json は {"mcpServers": {...}} と、直書きの {...} の両方がある
return data.get("mcpServers", data) if isinstance(data, dict) else {}
def refs_in(value):
if isinstance(value, str):
return set(REF.findall(value))
if isinstance(value, list):
return set().union(*(refs_in(v) for v in value)) if value else set()
if isinstance(value, dict):
return set().union(*(refs_in(v) for v in value.values())) if value else set()
return set()
def main(root: str) -> int:
base, risky = Path(root), 0
files = [base / ".mcp.json", base / ".claude-plugin" / "plugin.json"]
for f in files:
if not f.exists():
continue
for name, cfg in servers_of(f).items():
if not isinstance(cfg, dict):
continue
for field in ("command", "args", "env", "headers", "url"):
for var in sorted(refs_in(cfg.get(field))):
ok = var in BUILTIN
risky += 0 if ok else 1
print(f"{'OK ' if ok else 'RISK'} {f.name} server={name} {field}: ${{{var}}}")
print(f"--- 組み込み以外の参照: {risky} 件")
return 1 if risky else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "."))先ほどの testmo の設定に、既定値つきの参照と ${CLAUDE_PLUGIN_DATA} を足したサンプルで動かすと、次のように出ました。
OK .mcp.json server=testmo env: ${CLAUDE_PLUGIN_DATA}
RISK .mcp.json server=testmo env: ${TESTMO_API_TOKEN}
RISK .mcp.json server=testmo env: ${TESTMO_BASE_URL}
--- 組み込み以外の参照: 2 件終了コードは、RISK があれば 1 になります。配布前の CI やコミット前のフックに置けば、うっかり混ぜた参照を機械で止められます。
なぜこう書いたかを二点だけ補います。BUILTIN を定数に切り出したのは、増えたり減ったりしたときに一か所だけ直せば済むようにするためです。${NAME:-既定値} の形も拾うのは、既定値つきでも組み込み以外なら同じ扱いになる可能性があり、見逃したくなかったからです。
値を「渡す」のをやめて、サーバーに「読ませる」案
三つ目の対処は、私の設計案であり、Cowork では未検証です。誤解のないよう、そう書いておきます。
報告を素直に読むと、設定ファイルの中で名前を指して値を渡す経路が塞がれている、ということになります。であれば、値は設定に載せず、サーバー自身が実行時に読む形へ寄せる手があります。たとえば、組み込みで展開されると報告されている ${CLAUDE_PLUGIN_DATA} の配下に、利用者が自分でトークンのファイルを置き、サーバーの起動スクリプトがそれを読む、という形です。
ただし、この案には副作用があります。秘密がファイルとして置かれるので、権限と共有の扱いが一段厳しくなります。フォルダを丸ごと共有する運用なら、鍵の露出を数える作業が別途必要になります。この点はフォルダを AI に渡す前に鍵を数えた記録に書いたとおり、ファイル名だけでは見つかりません。
また、報告者自身は、これが意図した制限なのかどうかを判断できずに書いています。意図した制限であれば、許可される名前の一覧が公開され、利用者が値を渡す正式な経路が示されるはずです。それまでは、手元で回避策を作り込むより、プラグインを配る側が「この MCP は Cowork では鍵が渡らない場合があります」と先に書いておくほうが、受け取る人の時間を奪わないと考えています。
切り分けの結果を、どこまで信じるか
ログに警告が出て、組み込み以外の参照が見つかったとしても、それだけで「この症状はこれが原因だ」と言い切ることはできません。警告は設定の読み込み時に出るもので、実際にサーバーへ何が渡ったかは、サーバー側で確かめるしかないからです。
確実にしたいときは、サーバーの起動直後に、環境変数の「値」ではなく「先頭が $ かどうか」だけをログに残す、という確認が使えます。値そのものを出力すると、今度はログに鍵が残ってしまうためです。
# サーバーの起動スクリプトに足す確認(値は出さず、展開されたかどうかだけを記録する)
case "${TESTMO_API_TOKEN:-}" in
'') echo "token: empty" >&2 ;;
'${'*) echo "token: left as literal placeholder" >&2 ;;
*) echo "token: set (length ${#TESTMO_API_TOKEN})" >&2 ;;
esac出力先を標準エラーにしているのは、MCP の標準入出力を汚さないためです。標準出力に何か書くと、プロトコルの応答が壊れます。
この三分岐の「空」「展開されないまま」「設定済み」を見分けられれば、認証失敗の原因が鍵の誤りなのか、展開の問題なのかを分けられます。
鍵の中身は見ず、渡った形だけを見ます。 この線引きだけは、急いでいる夜でも守るようにしています。
次の一歩
まずは、お手元のプラグインのフォルダに洗い出しスクリプトを一度かけてみてください。RISK が 0 件なら、この記事の症状は心配いりません。1 件以上あれば、ログで outside the MCP stdio safelist を検索して、同じ名前が並んでいるかを見比べるところから始めていただければと思います。
Issue #94395 が動いたときは、この記事も組み込みの名前の一覧と合わせて書き直します。