◉CLAUDE LABJP
●CODE 2.1.322 — Fast mode now works on Vertex in the global, us and eu regions (11/10), falling back to standard speed if refused●FABLE 5.2 — Claude Code 2.1.321 makes Fable 5.2 the default Fable model: 1M context at $8/$40 per Mtok●MANAGED — Self-hosted Claude Managed Agents is out (11/12): you run the whole service yourself●SONNET 4.5 — Retires on the Claude API on 11/30, 17 days left. Migrate to Sonnet 5.5●MCP APPS — Users report local MCP app views no longer showing in Chat after a desktop update●NEW — Cowork or Claude Code First, With No Terminal Experience●CODE 2.1.322 — Fast mode now works on Vertex in the global, us and eu regions (11/10), falling back to standard speed if refused●FABLE 5.2 — Claude Code 2.1.321 makes Fable 5.2 the default Fable model: 1M context at $8/$40 per Mtok●MANAGED — Self-hosted Claude Managed Agents is out (11/12): you run the whole service yourself●SONNET 4.5 — Retires on the Claude API on 11/30, 17 days left. Migrate to Sonnet 5.5●MCP APPS — Users report local MCP app views no longer showing in Chat after a desktop update●NEW — Cowork or Claude Code First, With No Terminal Experience
Articles/Cowork
◈ Cowork/2026-11-13Intermediate

MCP Apps Widgets Stopped Showing After a Claude Desktop Update — Put a Logger Between stdio and Count Who Asked for What

After reports that Claude Desktop 4.23118.0 no longer renders MCP Apps from local stdio servers, I wrote a small logger you can put in front of any stdio server to see whether resources/read ever arrives and which connection called the tool.

Claude Desktop4MCP61MCP Appstroubleshooting106Python31

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/read for the widget's ui:// 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/read and 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 knowWhere the log shows itIf it matches, whose problem
Does the tool definition carry a widget URI?with_ui on the tools/list replyEmpty means the server
Which connection called the tool?client on the tools/call lineInput for triage
Does the call result carry _meta?meta on the tools/call replyMissing means the server
Did anyone fetch the widget?Presence of a resources/read lineDefinition 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:

  1. In a new chat with no folder attached, send one request that uses the widget tool
  2. If no panel appears, send "please open it again" in the same chat
  3. 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 -3

If 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. Its initialize line 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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◈ Cowork2026-11-02
Only the Plugin's MCP Server Fails to Authenticate — Triage for an Unexpanded ${VAR}
If only a plugin-bundled MCP server fails to authenticate in Cowork, its ${VAR} placeholders may be reaching the server as literal text. A log check and a pre-distribution scan, based on the report in issue #94395.
◈ Cowork2026-10-29
When a Share Card Lost Its Bottom Edge: Checking OG Images by Pixels, Not by Declaration, Before the Build
An OG image I had already fixed went back to 1200×1200, and the site name and tagline at the bottom were cut off in wide social cards. These are the checks I lined up before the build: declared size versus real file, the band lost to cropping, and a crawler-style request.
◈ Cowork2026-10-29
When Only the Back Half of a Subfolder CLAUDE.md Is Ignored, Count Characters, Not Bytes
Since Claude Code 2.1.309, a subfolder CLAUDE.md and path-scoped rules have a character limit, and anything past it is not handed over. This note covers a counting trap I hit on my own files, a scan script, a pre-commit guard, and how to split a file into rules.