Repository navigation
Bug Report: MCP Apps UI Resource Not Rendering in Claude Desktop / claude.ai #671
Description
Bug Report: MCP Apps UI Resource Not Rendering in Claude Desktop / claude.ai
Reporter: Ernst Weyhausen (AMFXT — amfxt.com)
Date: 27 May 2026
Severity: Feature-blocking
Component: Claude Desktop (Windows) / claude.ai — MCP Apps iframe rendering
Summary
Claude (both Desktop and claude.ai) successfully negotiates io.modelcontextprotocol/ui capability with our MCP server, receives structuredContent alongside content in tool results, fetches the UI resource via resources/read, but does not render the HTML resource in an iframe. Instead, the text-only fallback message is shown:
[This tool call rendered an interactive widget in the chat. The user can already see the result — do not repeat it in text or with another visualization tool.]
No iframe or embedded UI is visible to the user.
Environment
• Claude Desktop: Latest Windows version (installed via winget install Anthropic.Claude, confirmed up to date 27 May 2026)
• claude.ai: Web interface, same account
• MCP Server: Custom Node.js/Express JSON-RPC handler at https://amfxt.com (also tested on http://localhost:1000)
• MCP Protocol Version: 2025-11-25
• MCP Apps Spec: SEP-1865, stable 2026-01-26
• SDK reference version: @modelcontextprotocol/ext-apps@1.7.2
• Connection method (claude.ai): Connected via claude.ai MCP connector settings
• Connection method (Desktop): mcp-remote proxy in claude_desktop_config.json
• OS: Windows 11
What Works (verified independently)
- Capability Negotiation ✅
Claude sends io.modelcontextprotocol/ui in the initialize request. Server logs confirm:
Initialize [ui=true] - Resource Discovery ✅
Claude calls resources/list and receives the UI resource declaration:
{
"resources": [{
"uri": "ui://amfxt/slidetune",
"name": "SlideTune Flight Comparison",
"description": "Interactive fare comparison with bags, pets, equipment pricing, sorting and filtering",
"mimeType": "text/html;profile=mcp-app"
}]
} - Tool Registration with _meta.ui ✅
tools/list returns the tool with the correct UI linkage:
{
"name": "AMFXT",
"_meta": {
"ui": {
"resourceUri": "ui://amfxt/slidetune"
}
}
} - Tool Result with content + structuredContent ✅
tools/call returns both text fallback and structured data per spec:
{
"content": [{ "type": "text", "text": "{...full JSON...}" }],
"structuredContent": { "route_summary": {...}, "Swiss (LX)": [...], ... },
"isError": false
} - Resource Read ✅
resources/read returns the HTML resource with correct MIME type and CSP:
{
"contents": [{
"uri": "ui://amfxt/slidetune",
"mimeType": "text/html;profile=mcp-app",
"text": "...2200 lines of interactive UI...",
"_meta": {
"ui": {
"csp": {
"resourceDomains": ["https://fonts.googleapis.com", "https://fonts.gstatic.com"]
},
"prefersBorder": false
}
}
}]
} - End-to-End Rendering in basic-host ✅
The identical server + UI combination renders correctly in the SDK's basic-host test harness (@modelcontextprotocol/ext-apps examples). The HTML:
• Receives structuredContent via postMessage (ui/notifications/tool-result)
• Parses flight data via loadFlightData()
• Renders interactive flight cards with sorting, filtering, bag sliders, price breakdowns
• Responds to host theme changes
Screenshot of working basic-host rendering attached separately. - MCP Inspector ✅
MCP Inspector confirms the full JSON-RPC exchange is correct — tools/call returns valid content + structuredContent, resources/read returns valid HTML.
What Does Not Work
Claude Desktop & claude.ai: No iframe rendered ❌
Despite the complete and correct protocol exchange above, no iframe appears in the conversation. The user sees only the text:
[This tool call rendered an interactive widget in the chat. The user can already see the result — do not repeat it in text or with another visualization tool.]
This message indicates the backend/model layer acknowledges a widget should be rendered, but the frontend does not display it.
HTML Resource Details
The UI resource (SlideTune) is a self-contained ~2200-line HTML file implementing the MCP Apps lifecycle per SEP-1865:
• Sends ui/initialize request with protocolVersion: '2026-01-26'
• Sends ui/notifications/initialized notification after handshake
• Handles ui/notifications/tool-input and ui/notifications/tool-result
• Handles ui/notifications/host-context-changed (theme, safe area insets)
• Responds to ui/resource-teardown
• Uses postMessage for all communication (no SDK dependency — raw JSON-RPC per spec)
• Has baked-data fallback: works both as standalone HTML (direct URL) and as MCP App (iframe with postMessage data delivery)
• CSP requirements: Google Fonts only (fonts.googleapis.com, fonts.gstatic.com)
Steps to Reproduce
- Connect to MCP server at https://amfxt.com (or http://localhost:1000 locally)
- Verify Initialize [ui=true] in server logs
- Ask: "Fly me from MLA to ZRH on 20 June"
- Observe: tool call succeeds, data returned, but no UI rendered
Expected Behavior
Per SEP-1865 §"Host calls tool → Host renders resource UI → Server returns result → UI receives result":
- Host calls tools/call → receives result with structuredContent
- Host fetches resource via resources/read using the resourceUri from _meta.ui
- Host renders HTML in a sandboxed iframe
- Host sends ui/notifications/tool-result to iframe via postMessage
- UI renders interactive flight comparison
Actual Behavior
Steps 1-2 occur (confirmed by server logs). Steps 3-5 do not occur. No iframe is created. The model's text response references a widget that the user cannot see.
Questions for Anthropic
- Is MCP Apps UI rendering enabled in Claude Desktop for Windows as of May 2026?
- Is there a feature flag, account setting, or plan tier requirement to enable it?
- Does mcp-remote proxy correctly forward the io.modelcontextprotocol/ui extension capability? (Claude Desktop config uses mcp-remote for the connection)
- The "url" key in claude_desktop_config.json (for direct Streamable HTTP without mcp-remote) was rejected with "not a valid MCP configuration" — is this supported?
- Is there a list of hosts that currently render MCP Apps? The spec's clients page references host support varying.
Attachments
• Server logs showing complete protocol exchange
• Screenshot of successful rendering in basic-host test harness
Initialize [ui=true]
===>>> method: [notifications/initialized] -> result: [{}]
===>>> method: [resources/list] -> result: [{"resources":[{"uri":"ui://amfxt/slidetune","name":"SlideTune Flight Comparison","description":"Interactive fare comparison with bags, pets, equipment pricing, sorting and filtering","mimeType":"text/html;profile=mcp-app"}]}]
===>>> method: [resources/list] -> result: [{"resources":[{"uri":"ui://amfxt/slidetune","name":"SlideTune Flight Comparison","description":"Interactive fare comparison with bags, pets, equipment pricing, sorting and filtering","mimeType":"text/html;profile=mcp-app"}]}]
===>>> method: [tools/list] -> result: [{"tools":[{"name":"AMFXT","annotations":{"title":"AMFXT","readOnlyHint":true,"openWorldHint":true,"destructiveHint":false,"idempotentHint":false},"description":"Unified flight search, airline equipment policy, and pet transport policy tool. One tool, one call.\n\nACTION DISPATCH — set 'action' to choose mode:\n\n FLIGHTS:\n flight_search → Search flights with all-inclusive pricing (bags, pets, equipment). Supports multiple extras in one search (e.g. pet + bicycle). Returns fares across 6 classes, on-time stats, star ratings, CO₂, booking links. Supports wildcard discovery ('---').\n\n EQUIPMENT POLICIES (29 types × 600+ airlines):\n equipment_lookup → One airline + one equipment type → full policy detail\n equipment_airline → One airline → all 29 equipment policies\n equipment_compare → One equipment type across 2-5 airlines\n equipment_search → All airlines for one equipment type (with optional filter)\n\n PET POLICIES (12 modes × 21 species × 626+ airlines):"}],"_meta":{"ui":{"resourceUri":"ui://amfxt/slidetune"}}}]}]
Initialize [ui=true]
===>>> method: [notifications/initialized] -> result: [{}]
===>>> method: [tools/list] -> result: [{"tools":[{"name":"AMFXT","annotations":{"title":"AMFXT","readOnlyHint":true,"openWorldHint":true,"destructiveHint":false,"idempotentHint":false},"description":"Unified flight search, airline equipment policy, and pet transport policy tool. One tool, one call.\n\nACTION DISPATCH — set 'action' to choose mode:\n\n FLIGHTS:\n flight_search → Search flights with all-inclusive pricing (bags, pets, equipment). Supports multiple extras in one search (e.g. pet + bicycle). Returns fares across 6 classes, on-time stats, star ratings, CO₂, booking links. Supports wildcard discovery ('---').\n\n EQUIPMENT POLICIES (29 types × 600+ airlines):\n equipment_lookup → One airline + one equipment type → full policy detail\n equipment_airline → One airline → all 29 equipment policies\n equipment_compare → One equipment type across 2-5 airlines\n equipment_search → All airlines for one equipment type (with optional filter)\n\n PET POLICIES (12 modes × 21 species × 626+ airlines):"}],"_meta":{"ui":{"resourceUri":"ui://amfxt/slidetune"}}}]}]
===>>> method: [resources/list] -> result: [{"resources":[{"uri":"ui://amfxt/slidetune","name":"SlideTune Flight Comparison","description":"Interactive fare comparison with bags, pets, equipment pricing, sorting and filtering","mimeType":"text/html;profile=mcp-app"}]}]

Activity
penguinkang commentedon Jun 3, 2026
Having the same issue on Mac version.
Cicada95 commentedon Jun 5, 2026
It could be related to claude desktop in general not rendering iframes anthropics/claude-ai-mcp#236 when using "3p"
fix(mcp): load inspector panel SDK from jsdelivr — esm.sh build never…
feat(mcp): serve ui:// MCP App resources + inspector spike (#385) (#597)
DrakenZA commentedon Jul 6, 2026
Can we please get an update on this?
I tried putting Fable on the issue and ended up burning through my full 8-hour limit with no progress.
Fable told me this is an Anthropic-side issue, and it feels like it has just been left hanging without any clear communication or resolution. That’s pretty frustrating, especially since Anthropic created the MCP protocol, but this part of the implementation does not seem to be working properly.
What makes it more confusing is that I’ve seen the Higgsfield MCP display widgets correctly, so it’s hard to understand whether this is a general MCP limitation, an Anthropic implementation issue, or some kind of inconsistent/special-case behavior.
Can someone please clarify what is actually going on here, whether this is a known issue, and what the expected path to resolution is?
georgebrindeiro-vtex commentedon Jul 7, 2026
I'm seeing the same thing on Claude Code for Web – implementation on the MCP Server seems fine, but nothing renders
primevalsoup commentedon Jul 12, 2026
I just started adding widget support to my MCP aggregator site and hit the same problem. After spending a good chunk of this afternoon working with Claude on it, I got things working (Grok and ChatGPT were already fine). I put together a small working reference implementation here: https://github.com/primevalsoup/mcp-apps-claude-demo
If I had to guess, you're probably hitting one or both of these, both spec-optional but Claude-required:
1. Your
resources/read_meta.uihas nodomain. Claude requiresdomain == sha256("<endpoint URL incl. /mcp>")[:32] + ".claudemcpcontent.com". Missing, and it fetches the resource, claims a widget rendered, then never places the iframe (exactly your symptom). It's deterministic and self-computable, not a partner credential. Send a wrong value and Claude even prints the expected one in an error.2. Your view may not be sending
ui/notifications/initializedin a way Claude accepts. It keeps the iframe reserved-but-hidden until it receives that notification. Sending it only after matching a specificui/initializeresponse can deadlock; I send it on any result-bearing reply plus a short timeout fallback.DidiStf commentedon Jul 19, 2026
Not sure this is the exact same root cause as the one described above — the failure mode I hit had explicit console errors and a mid-load crash, whereas this issue describes a silent no-op with a clean protocol exchange. But since the visible symptom ("widget doesn't render") is the same, I'm posting what I found in case it saves someone else the debugging time.
Setup: MCP server (JSON-RPC over HTTP, custom Next.js route) exposing a tool with
_meta.ui.resourceUripointing at aui://HTML resource. The resource's<script type="module">imported the App SDK straight from the CDN:Symptom:
tools/callcompleted successfully (structured content came back fine, visible in the text response), but Claude Desktop never showed an iframe with the app — no widget, nothing, just the plain text result.How we found it: opened Chrome DevTools inside Claude Desktop and watched the console while triggering the tool call. Two things stood out:
A hard crash from the sandboxed app harness while the resource's script was loading:
This fired inside the
mcp_apps?resource-src=...&dev=truesandbox frame, i.e. the iframe pipeline was engaging and our script was being evaluated — it just died partway through, beforeapp.connect()ever ran.A wall of CSP violations:
These turned out to be secondary noise (sourcemap fetches for the CDN-hosted chunks), not the actual blocker.
Root cause:
@modelcontextprotocol/ext-apps@1.7.4/app-with-depsserved through esm.sh pulls in a version ofzodthat doesn't match what the SDK's own type definitions expect (the SDK is built againstzod/v4and callsz.custom(...), which isn't present on whatever zod build esm.sh resolved for that CDN bundle). Because this throws at module-evaluation time, it aborts the whole<script type="module">beforeApp.connect()can run — so theui/initializehandshake with the host never happens, and the host has nothing to render.Fix that worked for us: stop loading the SDK from esm.sh at runtime; bundle it ourselves so there are zero external imports in the resource HTML.
npm install @modelcontextprotocol/ext-appsandnpm install -D esbuild(own project deps instead of a CDN URL).<script type="module">logic into a real.tsentry file that doesimport { App } from "@modelcontextprotocol/ext-apps".bundle: true, format: "iife", platform: "browser") that compiles that entry file into a single self-contained script with zod and everything else inlined — no externalimportstatements left anywhere.<script>...</script>(nottype="module", nosrc, nothing to fetch at runtime).resourceDomains/connectDomains: ["https://esm.sh"]entries from_meta.ui.csp— no longer needed since there's nothing external left to allowlist.After that, the console was clean (no
types.mjscrash, no CSP violations) and the widget rendered normally.If your
resources/readHTML is loading the App SDK (or any other dependency) from esm.sh/unpkg/jsdelivr at runtime, I'd open DevTools inside the Claude Desktop app itself, trigger the tool call, and watch the console for an uncaught error firing inside themcp_apps?...sandbox frame beforeapp.connect()would normally run. If you see one, self-bundling is worth trying before assuming it's a host-side rendering bug.Booyaka101 commentedon Jul 22, 2026
If it helps anyone hitting this: I kept losing time to exactly this silent
failure (app never renders, no error anywhere), so I built a small local debug
host for it. It renders your server's app through the same App Bridge +
double-iframe sandbox path as a spec-conformant client, logs every postMessage
frame live in a side panel, and runs 5 automated PASS/FAIL checks —
ui://resource resolves, CSP permits embedding/assets,
ui/initializehandshake,ui/notifications/initialized, and an app-initiatedtools/callround trip.All green against the official
@modelcontextprotocol/server-basic-reactexample:
A handshake that dies silently in a real client looks like this instead —
green up to
app-html-written, then three red chips telling you exactly whatnever happened:
There's also
--mode strictto reproduce restrictive-host behavior (emptyhostCapabilities, app'stools/callrejected with-32601),--jsonforCI, and
--log-file/--videoto capture evidence for bug reports. Repo:https://github.com/Booyaka101/mcp-app-debug — MIT, feedback very welcome.
Munazah-Mehdi commentedon Jul 22, 2026
Slightly different variant of this that might be useful to narrow down, in my case the iframe does render (unlike OP), it just never gets data.
resources/readworks,tools/callcompletes fine server-side (I added logging on my end, confirmed structuredContent is populated and comes back in ~2s), butapp.ontoolresultjust never fires client-side. View sits on its loading state forever.Ruled out the esm.sh/zod thing @DidiStf found since I'm bundling the SDK myself via Vite (single-file, no CDN imports at all).
The thing that stood out for me: it's specific to Claude Desktop's chat interface. Same server, same View, same tool, if I run it in Cowork instead it works every single time. Only chat hangs. Wondering if this ties into what @primevalsoup said about
ui/notifications/initializedtiming, if chat and Cowork handle "is the iframe ready" differently internally, that'd explain why one works and the other doesn't.Also tried both a big payload (8 items, ~10s to generate) and a small one (3 items, ~2s), both hang the same way in chat, both fine in Cowork. So doesn't seem to be a timeout thing.
For now I just have the View pull its own data if ontoolresult hasn't shown up ~4s after connect(). Server caches the last result, View calls a second app-only tool to grab it itself if the push never arrives. Works, but obviously shouldn't be necessary.
ataglianetti commentedon Jul 23, 2026
Independent reproduction, with one extra data point that may help narrow this down.
Same symptom: capability negotiation succeeds, the resource is fetched, and Claude
announces that an interactive widget was rendered — but no iframe ever appears. The
reply is narrated from
structuredContentinstead.The distinguishing test: I temporarily embedded a static, no-JS marker directly in
the resource's HTML body — a plain coloured block that is visible with zero script
execution. It never appeared either.
That rules out a whole class of causes downstream of the iframe:
mints a new URI)
If the host were creating the iframe and our code were failing inside it, the static
marker would still paint. It doesn't — so the HTML appears never to reach a visible
iframe at all. That seems to place the failure at iframe creation/mounting, not at
resource delivery or in-frame execution.
Setup (matching the compliance checklist in the original report):
text/html;profile=mcp-app_meta.ui.resourceUrion the tool; resource_meta(incl. CSP) returned on bothresources/listand theresources/readresultstructuredContentHTML/CSP itself does not appear to be the blocker
Also tried, no change: fresh conversation, and a full connector remove/re-add.
Question for maintainers: is iframe rendering gated by surface, plan tier, or
connector type — i.e. self-added custom connectors (Settings → Connectors) versus
reviewed/directory connectors? At least one directory connector is publicly documented
as rendering MCP Apps in Claude today, which would be consistent with a gating
difference rather than a protocol-compliance one. If there's a flag or entitlement
involved, documenting it would save a lot of guesswork.
Happy to re-test against a fix or run further diagnostics if useful.
Fix MCP Apps view handshake deadlock: send ui/notifications/initializ…
uxprojectsjok commentedon Jul 27, 2026
same problem here, also verified server-side
vuk88 commentedon Jul 30, 2026
bump, same problem Claude Desktop just omits the ui component response as if its not supported as local stdout mcp... If going trough proper https connector works just fine. Confirmed that response from the MCP server lands in exactly the same format to mcp-proxy stdout as it would if going via https.
3 remaining items
uxprojectsjok commentedon Aug 8, 2026
Follow-up to my earlier comment (2026-07-27) — found and fixed everything on our end, which makes the remaining symptom a cleaner data point.
What we fixed server-side (spec gaps + our own bugs, not host-side): missing
_meta.ui.csp.baseUriDomainsdeclaration, a missing reverse-proxy route that made our asset endpoints unreachable from the public domain at all, and missingAccess-Control-Allow-Origin/Cross-Origin-Resource-Policyheaders on the SDK-script and per-app-asset routes (dynamicimport()from the sandbox's isolated origin is CORS-gated regardless of same-origin intent). Also confirmed independently that declaringbaseUriDomainshas no effect on Claude.ai — the host still blocks our<base>tag withbase-uri 'self', so we route around it with a hard-coded absolute URL for the SDK import instead of relying on that CSP field at all.After all of that, the exact same resource rendered perfectly on Claude.ai web: iframe mounted, script ran,
ui/initializehandshake completed, app-initiatedtools/callsucceeded, sizing reported correctly. Full green across the board with a purpose-built diagnostic view (static marker → fullscreen → script → handshake → tool-call → size).Then, with zero server-side changes, it stopped rendering again — back to the exact same failure signature we'd already fixed (
base-uri 'self'violation on the<base>tag, plusnet::ERR_HTTP2_PROTOCOL_ERRORon the SDK script fetch). I verified viacurlat the moment of the regression that the server was serving byte-identical content with identical headers (CORS, CORP, content-type, etc.) to what worked minutes earlier — so this isn't a server-side regression, cache-busting issue, or stale deploy. Disconnecting and reconnecting the connector multiple times had no effect.This matches @Booyaka101's measurement above almost exactly ("6 of 10 renders mounted and never painted... 0 of 18 blanked" in a later window, same server) — just with the added confirmation that in our case the server response itself was verified unchanged at the exact moment of failure, not just assumed unchanged. Whatever's flaky appears to be entirely on the host side, possibly per-conversation sandbox provisioning rather than anything in the resource/CSP/CORS negotiation, since all of that already succeeded once with this exact resource.
uxprojectsjok commentedon Aug 8, 2026
Final data point, captured with explicit instrumentation rather than inferred from console noise: added a dedicated catch handler around the dynamic
import()in our diagnostic view. Forcingvisibility: visibleon the (host-hidden) container to inspect it directly shows:Everything downstream (handshake, fallback, tool-call, size) stayed in its initial/untouched state — confirming the
.then()callback of the import never ran at all. One single break point (the dynamic import of the SDK script itself failing at the network level, matching thenet::ERR_HTTP2_PROTOCOL_ERRORreported earlier), and everything else is a direct consequence of that, not a separate issue.flaav commentedon Aug 14, 2026
Reproduces on both Claude desktop and web, with production wire evidence + a protocol-version finding
Setup: a custom remote connector (Streamable HTTP, mcp-handler 2.1.0 on @modelcontextprotocol/server 2.0.0), one minimal static ui:// resource linked to a single tool, per SEP-1865 / ext-apps (2026-01-26). Added to Claude via Settings → Connectors.
Observed: the tool runs and returns normally, but no iframe mounts — text-only fallback — on both Claude desktop and Claude web. (anthropics/claude-ai-mcp#471 tracks the web case; the desktop repro is why I'm posting here.)
Production wire evidence. A temporary unauthenticated endpoint drives the same mcp-handler through initialize → tools/list → resources/list → resources/read at three requested protocol versions and returns exactly what the live server serializes (schema + the static widget HTML only — no user data):
{
"widget": { "uri": "ui://dash/overview", "mimeType": "text/html;profile=mcp-app", "domain": "af9362428316c0d6010f12cabe9b7b7d.claudemcpcontent.com", "htmlLen": 778 },
"perProtocolVersion": [
{
"requestedProtocol": "2025-11-25",
"negotiatedProtocol": "2025-11-25",
"serverCapabilities": { "resources": { "listChanged": true }, "tools": { "listChanged": true } },
"toolDef_meta": { "ui": { "resourceUri": "ui://dash/overview" } },
"resourceList_meta": { "mimeType": "text/html;profile=mcp-app", "_meta": { "ui": { "domain": "af9362428316c0d6010f12cabe9b7b7d.claudemcpcontent.com", "csp": { "connectDomains": [], "resourceDomains": [] } } } },
"resourceRead": { "mimeType": "text/html;profile=mcp-app", "_meta": { "ui": { "domain": "af9362428316c0d6010f12cabe9b7b7d.claudemcpcontent.com" } }, "htmlLen": 778 }
}
// requestedProtocol "2026-01-26" and "2026-07-28": byte-identical, both negotiatedProtocol "2025-11-25"
]
}
Every spec-required field is correct on the wire: _meta.ui.resourceUri on the tool definition and the tools/call result; resources/list+resources/read serve mimeType exactly text/html;profile=mcp-app with _meta.ui.domain present; _meta.ui.csp empty (self-contained HTML).
Ruled out: serialization (above); server capability (none is required — ext-apps' registerAppResource/registerAppTool declare none, they're thin wrappers); deploy timing (confirmed Ready/Production before the test); the _meta.ui.domain hash (matches the exact registered server URL); schema caching (fresh conversation + reconnect).
Protocol-version data point (affects every TypeScript server). The connection negotiates 2025-11-25 even when the client requests 2026-01-26 or 2026-07-28 (the three rows). Both SDKs — @modelcontextprotocol/server 2.0.0 and the classic @modelcontextprotocol/sdk 1.30.0, both latest — cap LATEST_PROTOCOL_VERSION at 2025-11-25; 2026-07-28 (which folds MCP Apps into the core spec) isn't implemented in any published TS SDK yet. So no TypeScript MCP server can currently negotiate above 2025-11-25. Flagging in case the rendering path is sensitive to the negotiated version — if so it would affect all TS-based custom connectors.
devty commentedon Aug 26, 2026
Resolved on our side, and the cause was not MCP Apps at all — a missing
ping. Posting because the symptoms were identical to this thread and the fix is invisible to anyone using an SDK.Short version: our widget never mounted, everything in the MCP Apps path was conformant, and the actual cause was that our hand-rolled server answered
-32601toping. The host marked the connector unreachable and declined to instantiate widgets from it — while continuing to route tool calls to it perfectly.Setup. Hand-rolled JSON-RPC over a Next.js route handler on Vercel; Streamable HTTP, stateless per request; zero
@modelcontextprotocolpackages — the dispatch, theui://resource and the View are all hand-written. claude.ai custom OAuth connector (DCR + PKCE), used from the claude.ai web view in Claude Desktop on macOS. SEP-1865 per the 2026-01-26 spec; announced MCPprotocolVersion2025-06-18.Symptoms, matching OP exactly.
tools/callsucceeded and wrote to our database. The host injected into the tool result:…and no iframe appeared. Worth stressing how bad that particular string is when the widget does not mount: it instructs the model not to describe the result in prose. The failure is not "no card" — it is "no card and a suppressed text answer", so binding a widget made the tool strictly worse than having none.
What actually fixed it. Alongside the render failure we also saw an
Unable to reach <server>banner on the connector, and assumed for most of a day that they were the same bug. Hovering the banner showed a tooltip carrying our own error string:Our dispatcher implemented
initialize,tools/list,resources/list,resources/readandtools/call— and notping. Adding it (return an empty result) made both the banner and the render failure go away in the same deploy. Cards have rendered on every attempt since.How we know it was
pingand not something else we shipped that day. We had two widgets on deliberately different code paths: a style-stamp card on our original hand-rolled handshake, and a newer field-assignment card on a rewritten runtime that also added a visible pre-render state and changed how it reports height. Thepingfix was server-wide; the runtime changes touched only the newer card. Both render now, including the one that got none of the runtime changes — so the only variable they share isping.Please do not read this as the answer to this thread. Every other report here uses
ext-appsor@modelcontextprotocol/sdk, and an SDK answerspingfor you, so this cannot be most people's cause. Hand-rolling the dispatcher is what made the bug possible and also what made it findable. What I think it is worth as a contribution:pingbefore auditing your widget.Things we verified conformant, that were never the problem:
allow-scripts allow-same-origin). We served the exact bytes a host receives to a stub host in a real browser and drove the SEP-1865 sequence:ui/initialize→initialized→tool-result→size-changed. It goes silent only withoutallow-same-origin, which the spec forbids._meta.ui.resourceUrion the tool;text/html;profile=mcp-app; aui://URI;resources/list+resources/readboth correct.structuredContentstripping (Claude Desktop strips structuredContent from ui/notifications/tool-result — views built per SDK examples render blank #696). We carry a fallback: a bare id in_meta, from which the view fetches its own data via avisibility: ["app"]tool. Both tiers work._meta.ui.domain— we omit it entirely and still render, consistent with @Booyaka101's measurement that absent still mounts.2025-06-18, below anything a TS SDK can reach (@flaav found they cap at2025-11-25), and render fine. Combined with their result, MCP Apps rendering works across at least2025-06-18–2025-11-25from two independent implementations.One separate bug we hit and fixed that may be worth knowing if you are on a custom domain: if your
WWW-Authenticatepoints at protected-resource metadata whoseresourcevalue names a different origin than the one the client is talking to, registration fails with an opaque "couldn't register with the sign-in service" — RFC 9728 requires the client to check that value. Ours served the API happily on both a custom domain and the generated*.vercel.appalias, but only one could complete OAuth, and an existing access token hid it until the next re-registration.Happy to share wire captures from a non-SDK server if that is useful to anyone narrowing this.
luisd-vegag commentedon Aug 27, 2026
Another data point on this, with a server-side wire trace that shows a variant of the symptom: in our case the host never issues
resources/readat all.Same terminal behaviour as the original report — the text fallback
[This tool call rendered an interactive widget...]and no iframe.Setup: Python MCP server (
fastmcp3.4.7 /mcp1.27.2), streamable-HTTP, deployed on Cloud Run, added to Claude Desktop as a custom remote connector (unauthenticated).What the host sends
I logged raw JSON-RPC at the ASGI layer. Four distinct clients hit the server per session; three of them declare the UI extension:
Worth noting for anyone else debugging this: the first
initializeis a bare probe that sendscapabilities={}. It's easy to read that line alone and wrongly conclude the host doesn't support MCP Apps — the actual chat clients do declare it, and nametext/html;profile=mcp-appexplicitly.resources/readis never called. Not once, across repeated sessions over ~an hour. The host declares UI support, enumerates the resource, invokes the tool — and never fetches the UI. This differs from the original report, whereresources/readreportedly succeeds and rendering still fails.server/discoveris also called before anything else.fastmcp3.4.7 answers it with an empty result and 4.0.0b4 with-32601 Method not found; the host falls back totools/listand tool invocation works normally, so this doesn't appear to be the blocker — but flagging it in case the UI association is expected to come fromdiscover.What the server sends
tools/list— both key shapes, sincegetToolUiResourceUriinext-appsreads nested-first-then-flat while some other hosts read flat only:resources/list:{ "uri": "ui://standup-board", "mimeType": "text/html;profile=mcp-app", "_meta": { "ui": { "csp": { "connectDomains": ["https://<our-cloud-run-host>"], "resourceDomains": ["https://unpkg.com"] } } } }tools/callresult also carries_meta(we return aToolResult(meta=...), since FastMCP'sapp=/meta=decorator args only decorate the tool definition and the result otherwise ships_meta: null):Tool
_metavalidates againstMcpUiToolMetaSchema({resourceUri?, visibility?}) from the ext-apps bundle. The resource is served as bundled HTML with the guest-sideui/initializehandshake implemented, and renders correctly in a hand-rolled host harness that performs the handshake overpostMessage— consistent with the original reporter's finding that the same server works in the SDK's basic-host harness.Question
Is MCP Apps rendering gated for custom/unlisted remote connectors? anthropics/claude-ai-mcp#471 reports the same text-only fallback on Claude Web for a custom remote connector with a spec-correct server (and notes the identical backend renders on ChatGPT); it was closed as not-planned/duplicate without an explanation. If it is gated, a documented statement would save people a lot of protocol-level debugging — the failure is completely silent from the server's side, since the host simply never fetches the resource.
Happy to supply full logs or grant access to the test server if that's useful.
itsjet26 commentedon Aug 28, 2026
Reproduction from a hand-rolled server that negotiates 2026-07-28 — with a cache control and a second carrier, both of which still fail
Same terminal symptom as the thread: capability negotiated, tool invoked, text fallback, no iframe. Posting because this setup answers a question @flaav explicitly flagged as untestable, and adds two controls I could not find elsewhere in the thread.
Setup. Hand-rolled JSON-RPC over a Next.js route handler; Streamable HTTP, stateless per request; zero
@modelcontextprotocolpackages — dispatch, theui://resource and the view are all hand-written. claude.ai custom OAuth connector (DCR + PKCE), exercised from the Claude desktop app.1. The protocol-version hypothesis is answered: it is not the cause
flaav noted that both TS SDKs cap
LATEST_PROTOCOL_VERSIONat 2025-11-25, so no TypeScript server can negotiate above it, and flagged that the rendering path might be sensitive to the negotiated version.Being hand-rolled, this server is not capped. It implements
server/discoverreturning a spec-shapedDiscoverResult:{ "resultType": "complete", "supportedVersions": ["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"], "capabilities": { "tools": { "listChanged": false }, "resources": { "listChanged": false, "subscribe": false }, "extensions": { "io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] } } }, "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "…", "version": "0.2.0" } }, "ttlMs": 3600000, "cacheScope": "private" }A conformant third-party host negotiates 2026-07-28 against it and renders the widget (§3). Claude still does not. So the version cap is not what is blocking SDK-based servers in this thread — servers stuck at 2025-11-25 and a server offering 2026-07-28 fail identically.
Worth noting for anyone testing this: Claude sends
MCP-Protocol-Version: 2026-07-28as a header while itsinitializeasks for2025-11-25, so a server that logs only one of the two will draw the wrong conclusion about what was negotiated.2. What the host actually does —
resources/readis never calledRaw JSON-RPC logged at the route handler. Per session, four clients connect (
Anthropic,Anthropic/Toolbox,Anthropic/ClaudeAI,claude-ai); three declare the extension:Methods observed, in order:
resources/readis never called. Not once, across many sessions. The host negotiates the extension, enumerates the resource inresources/list, invokes the tool, and does not follow the pointer. This matches @luisd-vegag's variant rather than the original report.3. The same server is conformant — 7/7 against a third-party host
Run against
npx mcp-app-debug(the harness @Booyaka101 published in this thread):The iframe mounts, the handshake completes, and a button inside the widget round-trips a
tools/callback through the server and renders the reply. Identical server, identical resource, identical bytes.4. Control A — it is not response caching
resources/readin this server advertisesttlMs: 3600000, so an obvious objection is that the host fetched once and is serving from cache while the trace only shows later sessions.Re-pointed the tool at
ui://…/spike-b7f3c1, a URI the host had never seen, and re-ran. Still zeroresources/read. The host is not suppressing a repeat fetch; it never fetches at all.5. Control B — the embedded-resource carrier fails the same way
If the pointer-fetch path were the only broken step, shipping the page directly should work. Returned the same HTML as an embedded resource in the tool result alongside the
_metapointer, with a distinguishing marker in the heading so the two copies could be told apart:{ "type": "resource", "resource": { "uri": "ui://…/spike", "mimeType": "text/html;profile=mcp-app", "text": "<!doctype html>…" } }It inlines as text. The full page arrives in the model's context as prose, prefixed
[Resource from … at ui://…], and no frame is mounted for the user.So this is not specific to the pointer-fetch step. Neither carrier defined by the extension produces a mounted frame from this connector.
What the server sends, for elimination
tools/list— both spellings, sincegetToolUiResourceUrireads nested-then-flat:tools/callresult carries the same_meta.resources/listandresources/read—mimeType: "text/html;profile=mcp-app", plusdomaincomputation is @primevalsoup's. Adding it changed nothing here — consistent with @Booyaka101's measurement that it is optional for mounting, and unsurprising given §2: it is consumed at a step that never runs.)ui/initialize→ui/notifications/initialized→ui/notifications/size-changed, withappInfoandappCapabilitiespopulated.Two things worth flagging to anyone still debugging this
appInfoandappCapabilitiesare both required onui/initialize, and omitting them gets the request rejected with-32603 path ["params","appInfo"].mcp-app-debugstill reports the handshake as PASS, because a view that sendsinitializedwithout awaiting the reply proceeds anyway — so a 7/7 run can conceal the view's first message being refused. Worth checking your own protocol log rather than trusting the summary line.Once you set
resultType: "complete"you must also sendttlMs— "servers MUST include caching hints on results withresultType: complete", and absent it "clients SHOULD assume a default of 0 (immediately stale)". Setting one without the other had this host re-runningserver/discoverbefore every singletools/call.The question
Given that the extension is negotiated to this connector with the exact mime type, the resource is enumerated, and neither the pointer nor the embedded carrier mounts — is MCP Apps rendering gated for custom/unlisted remote connectors? anthropics/claude-ai-mcp#471 asks the same and was closed without explanation.
The failure is completely silent server-side, which is why this thread keeps accumulating conformant servers. If it is gated, saying so in the docs would save a lot of protocol-level debugging. If it is not, §2 says where to look: the host takes the pointer and does not follow it.
Happy to supply full traces or a test endpoint.
Booyaka101 commentedon Aug 28, 2026
@itsjet26 a caveat on the 7/7 in your section 3, since that run is being read as evidence: the handshake check could pass on a rejected
ui/initialize. It matched the reply to the request by id and never looked at whether the payload was aresultor anerror. My bug, fixed in 0.6.1, but anything cited from an older run wants a look at the raw log rather than the summary line.The failure mode underneath it is the part worth passing on, because it produces this thread's symptom with an exchange that looks completely clean.
McpUiInitializeRequestSchemamarksappInfo,appCapabilitiesandprotocolVersionall required, none optional, so omitting any one of them gets the request refused with-32603and a path like["params","appInfo"]. A view that firesui/notifications/initializedwithout awaiting that reply carries on as though it connected. My harness still delivered the tool-result afterwards, which is exactly why the run looked healthy, but a host that treats the handshake as a gate has no reason to deliver anything to an app it never accepted.That shape looks a lot like @Munazah-Mehdi's variant, iframe renders and
ontoolresultnever fires. Checking it needs no tooling: log the reply to your ownui/initializeand see whether it carriesresultorerror. If chat and Cowork differ on whether they validate those params, that would explain one working and the other hanging.mdizzledk commentedon Sep 2, 2026
Sorry for the long response, but hope it can be used for debugging for someone as I am facing the same issues, but none of the suggested solutions work.
Symptom. Identical to OP on both Claude Desktop and claude.ai: capability negotiated,
resources/readserved, the placeholder injected, no iframe.1.
pingreturns-32601under2026-07-28— and an SDK does not answer it for you@devty reported a missing
pingas the cause on a hand-rolled server, and noted that "an SDK answerspingfor you, so this cannot be most people's cause." On the Python SDK at2026-07-28, that does not hold.pingis present in the method tables for2025-06-18and2025-11-25, but absent for2026-07-28. The runner validates the method against the version table before handler lookup, so the lookup raisesKeyErrorand is translated to-32601— even thoughpingis registered as a handler on the server:The result is asymmetric, which is what makes it easy to miss — a session-based probe looks healthy:
pingMcp-Session-Id){"jsonrpc":"2.0","id":2,"result":{}}— answered at the connection layer{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found","data":"ping"}}Other methods dispatch fine sessionlessly (
tools/list,resources/list,server/discoverall return results), so this is specific toping.Worked around with middleware, which runs before validation and lookup, calling
call_nextfirst so it disappears once the SDK handles it:Verified on the wire:
-32601before,{"result":{}}after, withtools/listandresources/listunchanged and unknown methods still correctly-32601.No change to rendering in either Claude client. Worth fixing regardless — a liveness method answering
-32601is a bug on its own — but it was not the blocker here.2.
app=AppConfig(...)decorates the tool definition only; the result carries no_meta.uiIndependent confirmation of what @luisd-vegag reported, with the fix. In FastMCP,
@mcp.tool(app=AppConfig(resource_uri=...))puts_meta.ui.resourceUrion the entry intools/list, but thetools/callresult ships without it:structuredContentis sent by FastMCP, so that part of the contract was never missing. Fixed by returning aToolResultwith explicit meta (both spellings, since hosts read nested-then-flat while others read flat only):Separately,
resources/readreturnscontents[0]._meta: nullunlessmeta=is passed to@mcp.resource(...); we added @primevalsoup's_meta.ui.domain(sha256(<endpoint incl. /mcp>)[:32] + ".claudemcpcontent.com") plus an explicit empty CSP there.No change to rendering, and no error naming a different expected domain — so the computed value was not rejected as wrong, consistent with @Booyaka101's and @itsjet26's measurements that
domainis not the gate.Net result
After all three changes, verified on the wire:
ping→{}sessionlesslytools/callresult carriesui.resourceUriandui/resourceUriresources/readservestext/html;profile=mcp-appwith_meta.ui.domainand_meta.ui.csp_meta.ui.resourceUri;structuredContentpresentClaude Desktop and claude.ai both still inject the placeholder and mount no iframe. Vibe renders the same server unchanged.
One consequence worth stating plainly
The placeholder string tells the model not to restate the result in prose. When the iframe does not mount, the user therefore loses both the widget and the text answer — binding a UI resource to a tool makes that tool strictly worse than leaving it unbound. Until this is resolved, that is a reason not to attach
AppConfigto any tool whose text output has value on its own.Happy to supply full wire captures from the Python stack if useful.
Nudge search_flights callers to render the html field as an Artifact
landonasbury commentedon Sep 9, 2026
hit the identical symptom
feat(mcp): serve ui:// MCP App resources + inspector spike (#385) (#597)
edipox commentedon Sep 18, 2026
Got a custom remote connector rendering in Claude Desktop, chat and Cowork both.
If you're stuck, these are worth checking in order rather than starting with whichever looks most likely. They're all server-side, they all fail silently, and each one hides the next.
Return
capabilities.extensions["io.modelcontextprotocol/ui"]oninitialize. If you build a server instance per request on a stateless transport,oninitializedwon't fire in time to react to what the client advertised — just declare it at construction. Harmless if the client doesn't speak the extension.Put
_meta.ui.resourceUrion thetools/callresult, not just thetools/listentry. Some frameworks only decorate the tool definition. Send both spellings,_meta.ui.resourceUriand_meta["ui/resourceUri"].Return
_meta.uion theresources/readcontent item too, withcsp— empty arrays if your view is self-contained. Only adddomainif an external API needs a stable origin to allowlist. Getting it wrong blocks mounting; leaving it out doesn't.Bundle the app SDK into your HTML. Importing it from a CDN at runtime throws during module evaluation and kills the script before the handshake gets a chance to run.
Send
ui/notifications/initializedunconditionally, with a short timeout as backup. Don't wait until you've recognised a particularui/initializeresponse shape. The host holds the iframe hidden until that notification lands, so if your view is waiting on a reply it doesn't recognise, you deadlock. Nothing renders and nothing errors.Put a content hash in your
ui://URI. Hosts cache the resource against the URI, so with a fixed URI you can be served a stale copy long after you've changed the view — looks exactly like your change doing nothing.Keep the old
ui://URI working as an alias. Hosts hang onto URIs from tool declarations they cached earlier and ask for those too. When one 404s you getUnable to reach <connector>.6 and 7 go together — do one without the other and you've just swapped one problem for another.
Telling the two
Unable to reachcauses apartA dead cached URI and a wrong
_meta.ui.domainlook identical in the UI, as @Booyaka101 pointed out. Check the request log: a stale URI shows both being asked for, one fine and one failing.A wrong
domainwon't show that split.One debugging tip
Work from a request log between the host and your server — what method, what URI, and whether the reply was a
resultor anerror. Don't go by what the model says it got. Hosts strip_metabefore the model ever sees it, so "there was no_metaon the result" tells you nothing about what you actually sent.analytics: register MCP Apps view events
feat(agent-link): render diagrams in the host via MCP Apps
theoldfather commentedon Oct 6, 2026
Symptom: In claude.ai (remote custom connector), tool calls succeed and return a valid result, but the UI doesn't mount. We saw "There was a problem displaying content from " or a blank space under the tool call. The tool result text said the UI was rendered. The behavior was intermittent at first: one render worked and a retry of the same call did not.
Setup: A Python server built on FastMCP 3.4.3 (Prefab UI 0.20.2, mcp 1.28.1). It is a remote streamable-HTTP server behind OAuth. Each tool returns a Prefab app, so the resource is
ui://prefab/tool/<hash>/renderer.htmlwith MIME typetext/html;profile=mcp-app. The shell loads the renderer from jsDelivr, and_meta.ui.csp.resourceDomainsdeclares that domain on both theresources/listentry and theresources/readcontent item. We do not set_meta.ui.domain.Cause we identified: The only thing that changed between a broken release and a working one was removing
website_urlfrom the server constructor, which setsserverInfo.websiteUrlin the initialize result. We had added it, along with renaming the server, a release or two earlier. The release that still had it failed to render. The next release, which differed only by that one-line removal and a version bump, rendered correctly again.What we ruled out first:
Caveats: This is a small before/after sample, so we haven't isolated whether
websiteUrlalone is the trigger, or whether it combines with the renamed server or a host-side change that coincided with our deploy. We found nothing in the docs or the MCP Apps spec sayingwebsiteUrlaffects rendering.Question: Does claude.ai read
serverInfo.websiteUrl(oricons) when deciding whether to mount an app, for example by comparing it with the connector URL's origin? If so, a note in the troubleshooting docs would help. If you see this symptom, try removingwebsiteUrlfrom your server info.