I was reading through an issue one evening when I noticed the reporter correcting their own theory twice in three days. First it was "this version broke it." Then "that log line was always there." Then "it only fails on the first turn." Each suspect dropped away in turn, and the honesty of that sequence is what made me stop and take notes.
The issue is #108122. After Claude Desktop on macOS auto-updated from 4.16336.0 to 4.23118.0, MCP Apps (the small interactive panels that appear alongside a tool result) from local stdio servers in claude_desktop_config.json stopped rendering in Chat. The tool runs, and its text result comes back. Only the panel is missing.
Here is the short version: you can't triage this kind of "it doesn't show up" by staring at the screen. The thread only moved once the reporter started counting, line by line, what passed between server and client. Below is a small logger you can put in front of any stdio server so you can do the same counting yourself.
What I want to say up front
This is based on the public report and its comments. As of November 13, 2026, the issue is still open and I can't find an official response. I haven't reproduced the symptom in Desktop myself. What I did verify is that the logger below behaves correctly against a dummy server in my own terminal.
The thread, in short:
- Nov 7: on 4.23118.0,
resources/readfor the widget'sui://resource is never sent - Nov 8: correction, the suspicious "Era probe verdict: legacy" log line was printed on every start under the old, working version too
- Nov 10: the widget fails only on the first turn of a new chat; calling the same tool on the next turn triggers
resources/readand the panel renders - Nov 11: another user reports the same pattern, it renders once a chat is already going, never on the opening request
There was also a comment from someone whose case turned out to be their own tool definition: _meta.ui.resourceUri was only attached on another host's code path. Two different causes, one identical symptom.
What to count so you know whose bug it is
Desktop does keep a log at ~/Library/Logs/Claude/mcp-server-<name>.log. But the thread established that the tool is actually called on a second connection that never appears in that log. The initialize and tools/call from a client calling itself local-agent-mode-<name> showed up only in the server's own stderr.
So Desktop's log alone can't show you the tool call. You need to record every connection, in one format, right in front of the server. These are the four things worth checking:
| What you want to know | Where the log shows it | If it matches, whose problem |
|---|---|---|
| Does the tool definition carry a widget URI? | with_ui on the tools/list reply | Empty means the server |
| Which connection called the tool? | client on the tools/call line | Input for triage |
Does the call result carry _meta? | meta on the tools/call reply | Missing means the server |
| Did anyone fetch the widget? | Presence of a resources/read line | Definition and result fine but no fetch means the client |
A logger that sits between stdio and the server
stdio MCP sends one JSON-RPC message per line. If you launch the real server as a child process, pass every line through untouched, and keep a copy, you don't have to change the server's code at all. The same wrapper works for a Python mcp SDK server or a Node one.
#!/usr/bin/env python3
"""Sit in front of a stdio MCP server and log every JSON-RPC line that passes.
Usage: python3 mcp_tap.py --log ~/mcp_tap.log -- <original command> [args...]
"""
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 is the MCP channel itself, so logs go to a file only (never print)
child = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
lock = threading.Lock()
pending = {} # request id -> method (to match replies)
client = {"name": "?"} # client name announced in initialize
def describe(direction, msg):
if direction == "->":
method = msg.get("method", "-")
if "id" in msg:
pending[msg["id"]] = method # register before forwarding (avoid racing the reply)
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())Three reasons it's written this way:
- Logs go to a file only. A single stray character on stdout is enough to break the connection
- The request id is recorded before forwarding to the child. Forward first, and a fast reply can come back before you know what it answers
- Each connection keeps its pid and client name. Desktop starts a separate process per connection, so a different pid means a different connection
My first version registered the request after writing it to the child. Against the dummy server the log still came out in the right order, but I realised it was only right by luck of timing and swapped the two lines. A logger that drops a line by a few milliseconds will, in my experience, drop it on exactly the night you need it.
Wire it into Desktop and compare turn one with turn two
Rewrite the server entry in claude_desktop_config.json so it goes through the logger. Move the original command and args behind -- unchanged.
{
"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 launches with a different PATH from your terminal, so full paths for python3 and node are the safer choice. Save, quit Desktop completely, reopen it, and try this:
- In a new chat with no folder attached, send one request that uses the widget tool
- If no panel appears, send "please open it again" in the same chat
- Read the log
# Per-connection handshakes, tool calls and widget fetches, in time order
grep -E ' -> (initialize|tools/call|resources/read) ' ~/mcp_tap.log | awk '{print $1, $2, $3, $5, $7}'
# Does the tool definition carry a widget URI?
grep ' <- tools/list ' ~/mcp_tap.log | tail -3If you're seeing what the report describes, nothing follows the first tools/call, and resources/read appears right after the second one. If you see with_ui=[], look at your tool definition before suspecting Desktop.
Where to stop once you've read the log
I try not to touch anything the log didn't point at. Make the invisible countable first. If you start editing config before counting, the symptom moves and you never learn what actually changed it.
- No URI in the definition: fix the server's tool definition, and if
next=True, check the following pages too - Definition and result are fine but the first turn has no
resources/read: same shape as the report. Work around it by calling again on turn two, and add your log to the issue - An unfamiliar client name on
tools/call: that's the second connection. Itsinitializeline should also be in the log, so compare the two side by side
For a different failure where only plugin-bundled servers break, see Only the Plugin's MCP Server Fails to Authenticate. On how far to trust the server list right after startup, see Once I passed a dozen MCP servers, I stopped trusting the startup list.
As an indie developer, the more small tools I connect to Desktop, the more "it doesn't show up" turns out to live on either side of the wire. If you have one local server handy, put the logger in front of it, call the tool twice in a fresh chat, and count the lines. That's where I'd start.