Claude Codeでplugin_errorsとmcp_server_errorsをCIの失敗条件にする
claude -pは不正なMCP設定を黙ってスキップして正常終了します。system/initのplugin_errorsとmcp_server_errorsをjqで検査し、読み込み失敗をCIで落とす方法です。
CIでclaude -pに--mcp-configを渡しても、設定の書き方に誤りがあれば、そのサーバーは読み込まれないまま実行が進み、終了コードは0です。落ちないので、MCPツールが使えていない状態でレビューや生成が「成功」します。これを検知する手段が、system/initイベントのplugin_errorsとmcp_server_errorsです。
2つのフィールドは、エラーがなければキーごと省略されます。CIでは「キーが存在し、配列が空でない」を失敗条件にすれば、読み込めていないプラグインとMCPサーバーを機械的に拾えます。
何が黙ってスキップされるのか
--mcp-configで渡した各エントリは、起動時に検証されます。検証に落ちたエントリはスキップされ、実行は続き、終了コードも0のままです。ドキュメントの例は、urlだけがあってtypeがないエントリです。
Claude Codeはtypeのないエントリをstdioサーバーとして読むため、urlだけの設定は構成ミスになります。スキップ時のメッセージはMCP server "<name>" has a "url" but no "type"; add "type": "http" (or "sse" / "ws") to this entryの形です。
つまり、次のようなJSONはCIで静かに無視されます。
{
"mcpServers": {
"docs": {
"url": "https://example.com/mcp"
}
}
}"type": "http"を足せば読み込まれますが、足し忘れたままでもclaude -pは何も言わずに走ります。人間が端末で実行するときは警告が出るので気づけますが、CIではその警告が出ない条件がそろっています。
警告が見えるのは、stderrを捨てていない端末だけ
手元の端末で実行すると、Claude Codeは起動時にstderrへWarning: 1 MCP server skipped due to invalid config:のような警告を出し、スキップした理由を1件ずつ並べます。ところがstderrをリダイレクトしたとき、またはCIランナーやSDKホストのようなプログラムが出力を捕まえているときは、この警告が出ません。
そのため、スキップの事実が残る場所はmcp_server_errorsだけになります。警告の文言をログからgrepする方式はCIでは成立しません。この警告は--mcp-configの検証と同じく、Claude Code v2.1.219以降の挙動です。
system/initの2つの配列を読む
system/initは、モデル、ツール、MCPサーバー、読み込まれたプラグインなどのセッション情報を載せるイベントです。--output-format stream-jsonのストリームでは、起動前のイベント(plugin_installやhookのイベント)が先に流れる場合を除き、最初に届きます。
plugin_errors
プラグインの読み込み失敗を並べた配列です。各要素にはplugin、type、messageが入ります。依存プラグインのバージョンが満たされない場合や、--plugin-dirで渡したパスが存在しない、アーカイブが不正といった読み込み失敗が含まれます。読み込めなかったプラグインはpluginsにも出ません。
--plugin-dirで渡したディレクトリやアーカイブそのものが読み込めなかったときは、解決済みの絶対パスがpathとして入ります。複数の--plugin-dirのどれが壊れたかを特定する手がかりです。pathフィールドはv2.1.283以降で使えます。
Agent SDKの型定義では、plugin_errorsのtypeはpath-not-foundやmanifest-validation-errorのような、開いた集合のカテゴリとして説明されています。知らない値は一般的な失敗として扱う前提です。
なお、プラグイン自体は読み込まれても、hooksファイルのような一部だけが欠けた場合にも、plugin_errorsに項目が出るとSDKの型定義にあります。「pluginsに名前があるから大丈夫」とは言い切れないため、pluginsの有無ではなくplugin_errorsの空かどうかで判定します。
mcp_server_errors
--mcp-configのエントリのうち、検証でスキップされたものを並べた配列です。各要素にはname、type、messageが入ります。スキップされたサーバーはmcp_serversにも出ません。この配列はv2.1.219以降で使えます。
typeはスキップの分類で、ドキュメントに挙がっているのは次の4種類です。
| type | どんなエントリか |
|---|---|
unknown_type | どんなエントリか認識できないtypeが書かれている |
url_missing_type | どんなエントリかurlはあるがtypeがない |
invalid_config | どんなエントリか設定として検証に通らない |
reserved_name | どんなエントリか組み込みサーバーの予約名と重なっている |
reserved_nameの予約名には、workspace、claude-in-chrome、computer-use、Claude Preview、Claude Browserが含まれます。.mcp.jsonの流用などでこれらの名前をそのまま使うと、読み込み時にスキップされます。
ここで大事なのは、分類を網羅しようとしないことです。ドキュメントは「知らない値は一般的なスキップとして扱う」ことを求めています。CIの判定ではtypeの値で分岐せず、配列が空でなければ失敗にします。typeはログに出す原因の手がかりに回します。
配列の中身はこう見える
実際の出力形式は、ドキュメントの項目説明に沿うと次のような形になります。値は説明用の例で、実機の出力そのものではありません。
{
"type": "system",
"subtype": "init",
"mcp_servers": [],
"mcp_server_errors": [
{
"name": "docs",
"type": "url_missing_type",
"message": "MCP server \"docs\" has a \"url\" but no \"type\""
}
]
}jqでCIの失敗条件にする
stream-jsonは1行1イベントのJSON Linesです。--verboseと組み合わせて出力をファイルに落とし、system/initの行を取り出して判定します。
claude -p "変更点を要約して" \
--output-format stream-json --verbose \
--mcp-config ./ci-mcp.json \
--plugin-dir ./my-plugin > run.jsonl
jq -e -s '
[ .[] | select(.type == "system" and .subtype == "init") ][0]
| ((.plugin_errors // []) + (.mcp_server_errors // []))
| length == 0
' run.jsonljq -eは、最後の出力がfalseかnullのときに終了コード1を返します。読み込みエラーが1件でもあればlength == 0がfalseになり、ジョブが落ちます。// []は、キーが省略されているときに空配列として扱うための指定です。
原因をログに残したいなら、失敗時にエントリを書き出す形に分けます。
errors=$(jq -c -s '
[ .[] | select(.type == "system" and .subtype == "init") ][0]
| { plugins: (.plugin_errors // []), mcp: (.mcp_server_errors // []) }
| select((.plugins | length) + (.mcp | length) > 0)
' run.jsonl)
if [ -n "$errors" ]; then
echo "読み込み失敗: $errors" >&2
exit 1
fistderrへは自分で出します。Claude Codeが警告を出さない環境でも、CI側が理由を表示できます。
system/initが出なかったときに通ってしまう
ここまでのjqはsystem/initの行を[0]で取り出します。claudeがinitを出す前に落ちてrun.jsonlが空だと、[0]はnullになり、// []で空配列扱いになるのでlength == 0はtrueを返します。起動に失敗したジョブが、検査の上では合格します。initの行が1つも無いときは失敗にします。
jq -e -s '
[ .[] | select(.type == "system" and .subtype == "init") ]
| if length == 0 then false
else (.[0] | ((.plugin_errors // []) + (.mcp_server_errors // [])) | length == 0)
end
' run.jsonlclaude自体の終了コードも捨てないようにします。リダイレクトの直後に$?を控えておけば、jqの検査を先に走らせたうえで、最後に元の終了コードを返せます。
set -eu
claude -p "変更点を要約して" --output-format stream-json --verbose \
--mcp-config ./ci-mcp.json > run.jsonl || status=$?
jq -e -s '...上の判定式...' run.jsonl
exit "${status:-0}"jqがfalseを返せばset -eでそこで止まり、通ったときだけclaudeの終了コードが最後に返ります。
実行の途中で落ちたときとの切り分け
この検査は「設定が読み込めたか」を見るもので、実行の成否は別です。ドキュメントでは、claude -pは成功時に終了コード0、実行が失敗したときは非0を返し、無効なフラグはstderrにエラーを出して実行前に止まります。認証切れのように実行中に起きる失敗は、結果として標準出力に書かれます。読み込みの検査と終了コードの確認は、両方をCIに入れるのが前提です。
MCPサーバーが接続待ちのときの扱い
-pで--mcp-configを渡すと、Claude Codeは保留中のサーバーの接続を待ってから最初のターンを実行します。待機の上限はMCP_TIMEOUTで、既定は30秒です。ツール一覧のキャッシュを持つリモートサーバーは待機を省き、system/initではpendingと表示され、最初のツール呼び出しで接続します。この待機はv2.1.221以降です。
つまり、mcp_serversのstatusがpendingでも、それだけで読み込み失敗とは限りません。mcp_server_errorsは「検証に落ちてスキップされたエントリ」だけを指すので、サーバーに実際につながったかどうかは別の確認になります。ここで取り上げた配列は、設定ファイルの書き間違いや予約名の衝突のような、起動前に決まる失敗を拾うためのものです。
判定を入れる場所の選び方
どこに検査を置くか
ジョブの最初の1回
ツールや設定を読み込む前提のジョブでは、本番のプロンプトを走らせる前に、軽い質問で
system/initだけを確認する構成が向きます。本番の実行と同じ呼び出し
本番の呼び出しの出力をそのまま
stream-jsonで保存し、あとからjqで検査すれば、追加のAPI呼び出しがいりません。Agent SDKを使うジョブ
SDKSystemMessageがplugin_errorsを宣言しているのはAgent SDK v0.3.283以降です。pluginsオプションで渡したパスが読み込めなかったときは、pluginフィールドがinline[0]のような位置を示すタグになるので、pathで照合します。Python版のリファレンスには
plugin_errorsの記載がありません。宣言があるのはTypeScript版の型定義だけなので、Pythonのジョブではinitのpluginsに期待する名前が出ているかで確かめます。
--mcp-configのスキップ検査を入れる前に、設定ファイルそのものをCIで点検する手もあります。プラグイン側のMCP設定の取りこぼしはclaude plugin validateでMCP設定の取りこぼしを見つけるで扱っています。
つまずきやすい点
落とし穴
`--strict-mcp-config`でスキップが防げると考える
--strict-mcp-configは--mcp-config以外のMCP設定を無視するフラグです。渡したエントリの検証を省くわけではありません。使い分けは--mcp-configと--strict-mcp-configの使い分けにまとめています。
-pの基本的な使い方や、出力形式の選び方はClaude Codeの-pモードでスクリプトやパイプラインを自動化する基本が入口です。複数のプラグインディレクトリをまとめて渡す場面では、CLAUDE_CODE_PLUGIN_DIRSで複数プラグインをセッション単位で読み込むも参考になります。
古いClaude Codeでは検査が素通りする
mcp_server_errorsはv2.1.219以降、plugin_errorsのpathはv2.1.283以降のフィールドです。それより古いClaude Codeでは、これらが出力に載らない可能性が高く、スキップがあっても// []が空配列として扱うため、検査は合格します。CIでClaude Codeのバージョンを固定していない、またはキャッシュされた古い版を使うジョブでは、claude --versionをログに残し、ゲートが前提とする版以上かを先に確かめると、素通りに気づけます。MCPの接続待ちはv2.1.221以降なので、pendingの見え方もこの版を境に変わります。