Issue を読んでいた夜、報告者の書き込みが三日のうちに二度、自分の見立てを訂正していく様子に手が止まりました。最初は「この版で壊れた」、次に「このログ行は前からあった」、そして「1 ターン目だけ出ない」。原因の候補が一つずつ消えていく、その順番がとても誠実だったのです。
話題は Issue #108122 です。macOS の Claude Desktop が 4.16336.0 から 4.23118.0 に自動更新されたあと、claude_desktop_config.json に書いたローカルの stdio サーバーの MCP Apps(ツール結果と一緒に出る小さな画面)が Chat に描かれなくなった、という報告です。ツールは呼ばれ、テキストの結果も返ります。出ないのは画面だけでした。
最初にお伝えしたいのは、この種の「出ない」は、画面を眺めていても切り分けが進まないということです。進んだのは、サーバーとクライアントのあいだで何が行き来したかを、報告者が一行ずつ数え始めてからでした。ここでは、その数え方を自分の手元で再現できるよう、どのサーバーにも挟める小さな記録係を書き残します。
先に断っておくこと
内容は公開されている Issue の報告とコメントに基づいています。2026 年 11 月 13 日の時点で Issue は open のままで、公式からの回答は見当たりません。私自身は Desktop 上で同じ症状を再現しておらず、下の記録係は、手元の端末でダミーのサーバーを相手に動作を確かめたところまでです。
報告の流れを短くまとめると、次のとおりです。
- 11 月 7 日の報告: 4.23118.0 では、画面用の
ui://リソースを取りに行くresources/readが一度も送られません - 11 月 8 日の訂正: 疑われた「Era probe verdict: legacy」のログ行は、正常に描けていた旧版でも毎回出ていました
- 11 月 10 日の発見: 新しいチャットの 1 ターン目だけ描かれず、次のターンで同じツールを呼ぶと
resources/readが届いて描かれます - 11 月 11 日: 別の利用者も「会話を始めたあとなら出る。最初の依頼だと出ない」と同じ傾向を報告しています
一方で、途中には「自分たちの場合は、ツール定義の側に _meta.ui.resourceUri を付け忘れていただけだった」というコメントもありました。同じ見た目の症状に、原因が二つ混ざっているのです。
何を数えれば、原因の持ち主が分かるのか
Desktop の ~/Library/Logs/Claude/mcp-server-<名前>.log にも通信の記録は残ります。ただ、報告の中で分かったのは、ツールを実際に呼んでいるのは、このログに出ていない二本目の接続だということでした。サーバー側の標準エラーにだけ、local-agent-mode-<名前> と名乗るクライアントからの initialize と tools/call が現れていたそうです。
つまり Desktop 側のログだけを見ていると、ツールの呼び出しそのものが見えません。サーバーの手前で、全部の接続を同じ形式で記録する必要があります。確かめたいのは次の四つです。
| 見たいこと | 記録のどこで分かるか | 当てはまったときの持ち主 |
|---|---|---|
| ツール定義に画面の URI が付いているか | tools/list の返事の with_ui | 空ならサーバー側 |
| どの接続がツールを呼んだか | tools/call の行の client | 切り分けの材料 |
呼び出しの結果に _meta が載っているか | tools/call の返事の meta | 無ければサーバー側 |
| 画面を取りに来たか | resources/read の行の有無 | 定義も結果も正しいのに無ければクライアント側 |
stdio の間に挟む記録係を作る
stdio の MCP は、1 行に 1 つの JSON-RPC メッセージを流します。ですから、本来のサーバーを子プロセスとして起動し、行を素通しさせながら写しを取れば、サーバーのコードには一切手を入れずに済みます。Python の mcp SDK でも Node のサーバーでも、同じものが使えます。
#!/usr/bin/env python3
"""stdio MCP サーバーの手前に挟み、行き来した JSON-RPC を1行ずつ記録する。
使い方: python3 mcp_tap.py --log ~/mcp_tap.log -- <本来のコマンド> [引数...]
"""
import json, os, subprocess, sys, threading, time
args = sys.argv[1:]
log_path = os.path.expanduser("~/mcp_tap.log")
if args[:1] == ["--log"]:
log_path, args = os.path.expanduser(args[1]), args[2:]
if args[:1] == ["--"]:
args = args[1:]
if not args:
sys.exit("usage: mcp_tap.py [--log PATH] -- command [args...]")
# stdout は MCP の通信路なので、記録は必ずファイルへ(print は使わない)
child = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
lock = threading.Lock()
pending = {} # 問いの id -> method(返事を引き当てるため)
client = {"name": "?"} # initialize で名乗ったクライアント名
def describe(direction, msg):
if direction == "->":
method = msg.get("method", "-")
if "id" in msg:
pending[msg["id"]] = method # 子へ渡す前に登録する(返事との競争を避ける)
if method == "initialize":
client["name"] = msg.get("params", {}).get("clientInfo", {}).get("name", "?")
if method == "resources/read":
return method, f"uri={msg.get('params', {}).get('uri')}"
return method, ""
method = pending.pop(msg.get("id"), "-")
result = msg.get("result") or {}
if method == "tools/list":
tools = result.get("tools", [])
ui = [t["name"] for t in tools if (t.get("_meta") or {}).get("ui", {}).get("resourceUri")]
return method, f"tools={len(tools)} with_ui={ui} next={bool(result.get('nextCursor'))}"
if method == "tools/call":
return method, f"meta={sorted((result.get('_meta') or {}))} error={'error' in msg}"
return method, ""
def log(direction, msg, note):
stamp = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
with lock, open(log_path, "a", encoding="utf-8") as f:
f.write(f"{stamp}Z pid={os.getpid()} client={client['name']} "
f"{direction} {msg[0]} id={msg[1]} {note}\n")
def pump(src, dst, direction):
for raw in iter(src.readline, b""):
try:
msg = json.loads(raw)
method, note = describe(direction, msg)
ident = msg.get("id", "-")
except ValueError:
method, note, ident = "non-json", raw[:60], "-"
dst.write(raw)
dst.flush()
log(direction, (method, ident), note)
if direction == "->":
child.stdin.close()
threading.Thread(target=pump, args=(sys.stdin.buffer, child.stdin, "->"), daemon=True).start()
pump(child.stdout, sys.stdout.buffer, "<-")
sys.exit(child.wait())なぜこう書くのか、要点は三つです。
- 記録はファイルだけに書きます。stdout に一文字でも余計なものが混ざると、それだけで接続が壊れます
- 問いの id を、子に渡す前に控えます。先に渡すと、返事のほうが早く戻ってきて「何への返事か」を引けない瞬間が生まれえます
- 接続ごとに pid と client 名を残します。Desktop は接続のたびに別のプロセスを起動するので、pid が違えば別の接続だと分かります
最初の版では、問いの登録を「子へ書き込んだあと」に置いていました。手元でダミーのサーバーを相手に流したところ、記録は正しく並んだものの、順序に頼った書き方だと気づいてすぐ入れ替えました。数ミリ秒の差で記録が欠ける道具は、肝心な夜に限って欠けるものです。
Desktop に挟んで、1 ターン目と 2 ターン目を比べる
claude_desktop_config.json の該当サーバーを、記録係を通す形に書き換えます。元の command と args を -- の後ろへそのまま移すだけです。
{
"mcpServers": {
"my-local-app": {
"command": "/usr/bin/python3",
"args": [
"/Users/you/mcp/mcp_tap.py",
"--log", "/Users/you/mcp_tap.log",
"--",
"/usr/local/bin/node", "/Users/you/mcp/server.js"
]
}
}
}Desktop は端末とは違う PATH で起動するので、python3 も node もフルパスで書いておくほうが確実です。保存したら Desktop を完全に終了して開き直し、次の順で試します。
- フォルダを付けない新しいチャットで、画面付きのツールを使う依頼を一度だけ送ります
- 画面が出なければ、同じチャットで「もう一度開いてください」と送ります
- 記録を読みます
# 接続ごとの名乗りと、ツール呼び出し・画面の取得だけを時刻順に並べる
grep -E ' -> (initialize|tools/call|resources/read) ' ~/mcp_tap.log | awk '{print $1, $2, $3, $5, $7}'
# 定義の側に画面の URI が付いているか
grep ' <- tools/list ' ~/mcp_tap.log | tail -3報告と同じ状態なら、1 回目の tools/call のあとには何も続かず、2 回目の tools/call の直後に resources/read が現れるはずです。with_ui=[] と出たなら、Desktop を疑う前にサーバーのツール定義を見直す番です。
記録を読んだあと、どこで手を止めるか
私は、記録が示した場所以外には手を出さないようにしています。見えないものは、まず数えられる形に。 いま思えば、数える前に設定を書き換え始めると、何が効いたのか分からないまま症状だけが動いてしまうからです。
- 定義に URI が無い: サーバーのツール定義を直します。
next=Trueなら続きのページも確かめます - 定義も結果も正しく、1 ターン目だけ
resources/readが無い: Issue の報告と同じ形。2 ターン目で呼び直す運用でしのぎ、記録を添えて Issue に情報を足します tools/callの client が見慣れない名前: 二本目の接続です。その接続のinitialize行も記録に残っているはずなので、並べて見比べます
関連して、プラグイン経由のサーバーだけ失敗する別の型は「プラグイン同梱の MCP だけ認証に失敗するときの切り分け」に、起動直後の一覧をどこまで信じるかは「起動直後の MCP 一覧を信用しなくなった理由」に書いております。
個人開発で小さな道具を Desktop につなぐ機会が増えるほど、「出ない」の原因は自分の側にもクライアントの側にも散らばるのだと感じています。まずは手元のサーバー一つに記録係を挟み、新しいチャットで二度呼んで、記録の行数を数えるところから始めていただければ幸いです。