◉CLAUDE LABEN
●2.1.312 — 巨大な CLAUDE.md の上書きや、cd のあとにスキルが見つからない問題を修正しました●API — Messages API の最大リクエストサイズが 32MB から 128MB に拡大されました(10/28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り28日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST — Opus 4.8 の fast mode は非推奨になり、11/30 に除去されます。移行先は Opus 5.5●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました●2.1.312 — 巨大な CLAUDE.md の上書きや、cd のあとにスキルが見つからない問題を修正しました●API — Messages API の最大リクエストサイズが 32MB から 128MB に拡大されました(10/28)●SONNET 4.5 — Claude API での提供終了は 11/30、残り28日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST — Opus 4.8 の fast mode は非推奨になり、11/30 に除去されます。移行先は Opus 5.5●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました
記事一覧/Cowork
◈ Cowork/2026-11-02中級

プラグイン同梱の MCP だけ認証に失敗する — ${VAR} が展開されないときの切り分け

Cowork でプラグイン同梱の MCP サーバーだけ認証に失敗するとき、環境変数の ${VAR} が展開されないまま渡されている可能性があります。Issue #94395 の報告をもとに、ログでの確認と配布前の洗い出しスクリプトをまとめました。

Cowork62プラグイン8MCP60環境変数9トラブルシューティング66

プラグインに入れた 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 行にしか残りません。症状から原因まで辿れる人が少ないのは、このためだと感じています。

自分の環境で当てはまるかを三つの順で確かめます

疑う順番を決めておくと、胃の痛みはかなり軽くなります。私は次の順で見ます。

  1. ログに outside the MCP stdio safelist が出ているかを検索します。
  2. 出ていれば、そのプラグインの設定に、組み込み以外の ${…} があるかを数えます。
  3. あれば、その値を設定側に頼らず、サーバー自身に読ませる形へ寄せられるかを考えます。

一つ目は、ログフォルダ全体を対象に検索するだけです。報告者の 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 が動いたときは、この記事も組み込みの名前の一覧と合わせて書き直します。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

◈ Cowork2026-10-26
Cowork に渡した Dropbox のフォルダで「ファイルが見つからない」と言われたときの切り分け順
Finder には見えているのに Cowork が「見つからない」と返す。原因は Dropbox の「オンラインのみ」のファイルかもしれません。症状の見分け方、確認コマンド、直し方、渡すフォルダの線引きを順に書き残します。
◈ Cowork2026-10-20
コネクタのツール名32個を「読む・書く・消す」に仕分けたら、名前では決められないものが4つ残りました
Cowork に接続したコネクタのツール名32個を、動詞で「読む・書く・消す」に仕分けるスクリプトを書きました。一度目は9個が決められず、直しても4個残った理由と、承認の置き方の線引きを書き残します。
◈ Cowork2026-10-17
スケジュールタスクが SUCCEEDED のまま成果を出さなくなったら、最初の1分で外への通信を疑います
実行記録は緑なのに成果物が昨日のまま。外部への通信が CONNECT の 403 で止まっていないかを、短いプローブと鮮度チェックで最初の1分に見分ける手順をお伝えします。