Skip to content

Bug Report: MCP Apps UI Resource Not Rendering in Claude Desktop / claude.ai #671

Open

Description

@ernst-von

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)

  1. Capability Negotiation ✅
    Claude sends io.modelcontextprotocol/ui in the initialize request. Server logs confirm:
    Initialize [ui=true]
  2. 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"
    }]
    }
  3. Tool Registration with _meta.ui ✅
    tools/list returns the tool with the correct UI linkage:
    {
    "name": "AMFXT",
    "_meta": {
    "ui": {
    "resourceUri": "ui://amfxt/slidetune"
    }
    }
    }
  4. 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
    }
  5. 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
    }
    }
    }]
    }
  6. 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.
  7. 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

  1. Connect to MCP server at https://amfxt.com (or http://localhost:1000 locally)
  2. Verify Initialize [ui=true] in server logs
  3. Ask: "Fly me from MLA to ZRH on 20 June"
  4. 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":

  1. Host calls tools/call → receives result with structuredContent
  2. Host fetches resource via resources/read using the resourceUri from _meta.ui
  3. Host renders HTML in a sandboxed iframe
  4. Host sends ui/notifications/tool-result to iframe via postMessage
  5. 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

  1. Is MCP Apps UI rendering enabled in Claude Desktop for Windows as of May 2026?
  2. Is there a feature flag, account setting, or plan tier requirement to enable it?
  3. Does mcp-remote proxy correctly forward the io.modelcontextprotocol/ui extension capability? (Claude Desktop config uses mcp-remote for the connection)
  4. 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?
  5. 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"}]}]

Image

Activity

  1. penguinkang commented on Jun 3, 2026

    @penguinkang

    Having the same issue on Mac version.

  2. Cicada95 commented on Jun 5, 2026

    @Cicada95

    It could be related to claude desktop in general not rendering iframes anthropics/claude-ai-mcp#236 when using "3p"

  3. DrakenZA commented on Jul 6, 2026

    @DrakenZA

    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?

  4. georgebrindeiro-vtex commented on Jul 7, 2026

    @georgebrindeiro-vtex

    I'm seeing the same thing on Claude Code for Web – implementation on the MCP Server seems fine, but nothing renders

  5. primevalsoup commented on Jul 12, 2026

    @primevalsoup

    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.ui has no domain. Claude requires domain == 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/initialized in a way Claude accepts. It keeps the iframe reserved-but-hidden until it receives that notification. Sending it only after matching a specific ui/initialize response can deadlock; I send it on any result-bearing reply plus a short timeout fallback.

  6. DidiStf commented on Jul 19, 2026

    @DidiStf

    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.resourceUri pointing at a ui:// HTML resource. The resource's <script type="module"> imported the App SDK straight from the CDN:

    <script type="module">
      import { App } from "https://esm.sh/@modelcontextprotocol/ext-apps@1.7.4/app-with-deps";
      ...
    </script>

    Symptom: tools/call completed 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:

    1. A hard crash from the sandboxed app harness while the resource's script was loading:

      types.mjs:2 Uncaught TypeError: t.custom is not a function
          at types.mjs:2:209
      

      This fired inside the mcp_apps?resource-src=...&dev=true sandbox frame, i.e. the iframe pipeline was engaging and our script was being evaluated — it just died partway through, before app.connect() ever ran.

    2. A wall of CSP violations:

      Connecting to '<esm.sh URL>' violates the following Content Security Policy directive: "connect-src 'self'".
      

      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-deps served through esm.sh pulls in a version of zod that doesn't match what the SDK's own type definitions expect (the SDK is built against zod/v4 and calls z.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"> before App.connect() can run — so the ui/initialize handshake 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.

    1. npm install @modelcontextprotocol/ext-apps and npm install -D esbuild (own project deps instead of a CDN URL).
    2. Moved the inline <script type="module"> logic into a real .ts entry file that does import { App } from "@modelcontextprotocol/ext-apps".
    3. Added a small esbuild step (bundle: true, format: "iife", platform: "browser") that compiles that entry file into a single self-contained script with zod and everything else inlined — no external import statements left anywhere.
    4. Embedded that bundle directly into the resource HTML as a plain <script>...</script> (not type="module", no src, nothing to fetch at runtime).
    5. Removed the 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.mjs crash, no CSP violations) and the widget rendered normally.

    If your resources/read HTML 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 the mcp_apps?... sandbox frame before app.connect() would normally run. If you see one, self-bundling is worth trying before assuming it's a host-side rendering bug.

  7. Booyaka101 commented on Jul 22, 2026

    @Booyaka101

    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/initialize handshake,
    ui/notifications/initialized, and an app-initiated tools/call round trip.

    npx mcp-app-debug http://localhost:3001/mcp          # HTTP servers
    npx mcp-app-debug --stdio -- npx -y my-mcp-server    # stdio servers

    All green against the official @modelcontextprotocol/server-basic-react
    example:

    all checks passing

    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 what
    never happened:

    handshake timeout diagnosed

    There's also --mode strict to reproduce restrictive-host behavior (empty
    hostCapabilities, app's tools/call rejected with -32601), --json for
    CI, and --log-file/--video to capture evidence for bug reports. Repo:
    https://github.com/Booyaka101/mcp-app-debug — MIT, feedback very welcome.

  8. Munazah-Mehdi commented on Jul 22, 2026

    @Munazah-Mehdi

    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/read works, tools/call completes fine server-side (I added logging on my end, confirmed structuredContent is populated and comes back in ~2s), but app.ontoolresult just 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/initialized timing, 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.

  9. ataglianetti commented on Jul 23, 2026

    @ataglianetti

    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 structuredContent instead.

    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:

    • not a script error, bootstrapping failure, or host-API/data-bridge problem
    • not a CSP violation blocking sub-resources (the marker loads nothing external)
    • not a stale cached template (the resource URI is content-versioned, so any change
      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):

    • remote MCP server over Streamable HTTP, stateless
    • resource mimeType text/html;profile=mcp-app
    • _meta.ui.resourceUri on the tool; resource _meta (incl. CSP) returned on both
      resources/list and the resources/read result
    • tool returns text fallback + structuredContent
    • the same widget resource renders correctly in another MCP-Apps-style host, so the
      HTML/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.

  10. uxprojectsjok commented on Jul 27, 2026

    @uxprojectsjok

    same problem here, also verified server-side

  11. vuk88 commented on Jul 30, 2026

    @vuk88

    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.

  12. 3 remaining items

  13. uxprojectsjok commented on Aug 8, 2026

    @uxprojectsjok

    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.baseUriDomains declaration, a missing reverse-proxy route that made our asset endpoints unreachable from the public domain at all, and missing Access-Control-Allow-Origin/Cross-Origin-Resource-Policy headers on the SDK-script and per-app-asset routes (dynamic import() from the sandbox's isolated origin is CORS-gated regardless of same-origin intent). Also confirmed independently that declaring baseUriDomains has no effect on Claude.ai — the host still blocks our <base> tag with base-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/initialize handshake completed, app-initiated tools/call succeeded, 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, plus net::ERR_HTTP2_PROTOCOL_ERROR on the SDK script fetch). I verified via curl at 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.

  14. uxprojectsjok commented on Aug 8, 2026

    @uxprojectsjok

    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. Forcing visibility: visible on the (host-hidden) container to inspect it directly shows:

    2b. SDK-IMPORT — fehlgeschlagen: Failed to fetch dynamically imported module: https://agency.uxprojects-jok.com/apps/_sdk/app.js
    

    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 the net::ERR_HTTP2_PROTOCOL_ERROR reported earlier), and everything else is a direct consequence of that, not a separate issue.

  15. flaav commented on Aug 14, 2026

    @flaav

    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.

  16. devty commented on Aug 26, 2026

    @devty

    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 -32601 to ping. 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 @modelcontextprotocol packages — the dispatch, the ui:// 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 MCP protocolVersion 2025-06-18.

    Symptoms, matching OP exactly. tools/call succeeded and wrote to our database. The host injected into the tool result:

    [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.]

    …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:

    Method not found: ping
    

    Our dispatcher implemented initialize, tools/list, resources/list, resources/read and tools/call — and not ping. 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 ping and 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. The ping fix 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 is ping.


    Please do not read this as the answer to this thread. Every other report here uses ext-apps or @modelcontextprotocol/sdk, and an SDK answers ping for 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:

    1. A discriminator. If you hand-rolled your server and you see an "unable to reach" banner next to the render failure, they are probably two bugs, and the second one is yours. Check ping before auditing your widget.
    2. A caution about where the symptom surfaces. A liveness method broke rendering, while tool calls kept working — so every check that measured the server (200s in logs, real data flowing, rows written, an authenticated client working perfectly) came back healthy and told us nothing.
    3. Ruling things out for the rest of the thread, since our stack shares nothing with the SDK-based reports.

    Things we verified conformant, that were never the problem:

    • The document renders under the spec's own default CSP, and under the sandbox the spec mandates (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 without allow-same-origin, which the spec forbids.
    • _meta.ui.resourceUri on the tool; text/html;profile=mcp-app; a ui:// URI; resources/list + resources/read both correct.
    • Not structuredContent stripping (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 a visibility: ["app"] tool. Both tiers work.
    • Not _meta.ui.domain — we omit it entirely and still render, consistent with @Booyaka101's measurement that absent still mounts.
    • Not the protocol version. We announce 2025-06-18, below anything a TS SDK can reach (@flaav found they cap at 2025-11-25), and render fine. Combined with their result, MCP Apps rendering works across at least 2025-06-18–2025-11-25 from 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-Authenticate points at protected-resource metadata whose resource value 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.app alias, 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.

  17. luisd-vegag commented on Aug 27, 2026

    @luisd-vegag

    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/read at 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 (fastmcp 3.4.7 / mcp 1.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:

    initialize client={"name":"Anthropic","version":"1.0.0"}      capabilities={}
    server/discover
    initialize client={"name":"Anthropic/Toolbox","version":"1.0.0"}
        capabilities={"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}}
    initialize client={"name":"Anthropic/ClaudeAI","version":"1.0.0"}   capabilities={...same...}
    initialize client={"name":"claude-ai","version":"0.1.0"}            capabilities={...same...}
    tools/list
    resources/list
    prompts/list
    tools/call
    

    Worth noting for anyone else debugging this: the first initialize is a bare probe that sends capabilities={}. 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 name text/html;profile=mcp-app explicitly.

    resources/read is 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, where resources/read reportedly succeeds and rendering still fails.

    server/discover is also called before anything else. fastmcp 3.4.7 answers it with an empty result and 4.0.0b4 with -32601 Method not found; the host falls back to tools/list and 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 from discover.

    What the server sends

    tools/list — both key shapes, since getToolUiResourceUri in ext-apps reads nested-first-then-flat while some other hosts read flat only:

    "_meta": {
      "ui/resourceUri": "ui://standup-board",
      "ui": { "resourceUri": "ui://standup-board" }
    }

    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/call result also carries _meta (we return a ToolResult(meta=...), since FastMCP's app=/meta= decorator args only decorate the tool definition and the result otherwise ships _meta: null):

    "_meta": {
      "ui/resourceUri": "ui://standup-board",
      "ui": { "resourceUri": "ui://standup-board" }
    }

    Tool _meta validates against McpUiToolMetaSchema ({resourceUri?, visibility?}) from the ext-apps bundle. The resource is served as bundled HTML with the guest-side ui/initialize handshake implemented, and renders correctly in a hand-rolled host harness that performs the handshake over postMessage — 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.

  18. itsjet26 commented on Aug 28, 2026

    @itsjet26

    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 @modelcontextprotocol packages — dispatch, the ui:// 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_VERSION at 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/discover returning a spec-shaped DiscoverResult:

    {
      "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-28 as a header while its initialize asks for 2025-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/read is never called

    Raw JSON-RPC logged at the route handler. Per session, four clients connect (Anthropic, Anthropic/Toolbox, Anthropic/ClaudeAI, claude-ai); three declare the extension:

    capabilities={"extensions":{"io.modelcontextprotocol/ui":{"mimeTypes":["text/html;profile=mcp-app"]}}}
    

    Methods observed, in order:

    server/discover
    initialize          client=claude-ai  ui=true
    notifications/initialized
    tools/list
    resources/list
    tools/call          tool=<the ui tool>
    ping
    

    resources/read is never called. Not once, across many sessions. The host negotiates the extension, enumerates the resource in resources/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):

    negotiate  negotiated 2026-07-28 via server/discover
    connected  58 tool(s), protocol 2026-07-28
    resources/read  text/html;profile=mcp-app, 4973 bytes
    
    PASS  ui:// resource resolves
    PASS  CSP permits embedding & assets      _meta.ui.csp honored
    PASS  _meta.ui.domain origin
    PASS  ui/initialize handshake             17 ms
    PASS  ui/ready notification               22 ms
    PASS  app-initiated tools/call            app called a server tool -> non-error result
    PASS  protocol revision                   negotiated 2026-07-28 via server/discover
    7/7 checks passed
    

    The iframe mounts, the handshake completes, and a button inside the widget round-trips a tools/call back through the server and renders the reply. Identical server, identical resource, identical bytes.

    4. Control A — it is not response caching

    resources/read in this server advertises ttlMs: 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 zero resources/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 _meta pointer, 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, since getToolUiResourceUri reads nested-then-flat:
      "_meta": { "ui": { "resourceUri": "ui://…/spike" }, "ui/resourceUri": "ui://…/spike" }
    • tools/call result carries the same _meta.
    • resources/list and resources/read — mimeType: "text/html;profile=mcp-app", plus
      "_meta": { "ui": {
        "csp": { "connectDomains": [], "resourceDomains": [] },
        "domain": "<sha256(endpoint incl. /mcp)[:32]>.claudemcpcontent.com"
      } }
      (The domain computation 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.)
    • The view is a single self-contained HTML document, no external fetches, ui/initialize → ui/notifications/initialized → ui/notifications/size-changed, with appInfo and appCapabilities populated.

    Two things worth flagging to anyone still debugging this

    appInfo and appCapabilities are both required on ui/initialize, and omitting them gets the request rejected with -32603 path ["params","appInfo"]. mcp-app-debug still reports the handshake as PASS, because a view that sends initialized without 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 send ttlMs — "servers MUST include caching hints on results with resultType: complete", and absent it "clients SHOULD assume a default of 0 (immediately stale)". Setting one without the other had this host re-running server/discover before every single tools/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.

  19. Booyaka101 commented on Aug 28, 2026

    @Booyaka101

    @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 a result or an error. 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. McpUiInitializeRequestSchema marks appInfo, appCapabilities and protocolVersion all required, none optional, so omitting any one of them gets the request refused with -32603 and a path like ["params","appInfo"]. A view that fires ui/notifications/initialized without 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 ontoolresult never fires. Checking it needs no tooling: log the reply to your own ui/initialize and see whether it carries result or error. If chat and Cowork differ on whether they validate those params, that would explain one working and the other hanging.

  20. mdizzledk commented on Sep 2, 2026

    @mdizzledk

    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/read served, the placeholder injected, no iframe.


    1. ping returns -32601 under 2026-07-28 — and an SDK does not answer it for you

    @devty reported a missing ping as the cause on a hand-rolled server, and noted that "an SDK answers ping for you, so this cannot be most people's cause." On the Python SDK at 2026-07-28, that does not hold.

    ping is present in the method tables for 2025-06-18 and 2025-11-25, but absent for 2026-07-28. The runner validates the method against the version table before handler lookup, so the lookup raises KeyError and is translated to -32601 — even though ping is registered as a handler on the server:

    >>> from mcp_types import methods as M
    >>> M.validate_client_request("ping", "2025-11-25", {})   # ok
    >>> M.validate_client_request("ping", "2026-07-28", {})
    KeyError: ('ping', '2026-07-28')

    The result is asymmetric, which is what makes it easy to miss — a session-based probe looks healthy:

    Transport path Response to ping
    Session-based (Mcp-Session-Id) {"jsonrpc":"2.0","id":2,"result":{}} — answered at the connection layer
    Sessionless (our deployment) {"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found","data":"ping"}}

    Other methods dispatch fine sessionlessly (tools/list, resources/list, server/discover all return results), so this is specific to ping.

    Worked around with middleware, which runs before validation and lookup, calling call_next first so it disappears once the SDK handles it:

    async def _ping_fallback(ctx, call_next):
        if ctx.method != "ping":
            return await call_next(ctx)
        try:
            return await call_next(ctx)
        except MCPError as exc:
            if exc.code != METHOD_NOT_FOUND:
                raise
            return {}
    
    mcp._mcp_server.middleware.insert(0, _ping_fallback)

    Verified on the wire: -32601 before, {"result":{}} after, with tools/list and resources/list unchanged and unknown methods still correctly -32601.

    No change to rendering in either Claude client. Worth fixing regardless — a liveness method answering -32601 is 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.ui

    Independent confirmation of what @luisd-vegag reported, with the fix. In FastMCP, @mcp.tool(app=AppConfig(resource_uri=...)) puts _meta.ui.resourceUri on the entry in tools/list, but the tools/call result ships without it:

    tools/list  _meta: {"ui": {"resourceUri": "ui://…", "visibility": ["model"]}, "fastmcp": {"tags": []}}
    tools/call  _meta: {"fastmcp": {"wrap_result": true}, "io.modelcontextprotocol/serverInfo": {…}}
    

    structuredContent is sent by FastMCP, so that part of the contract was never missing. Fixed by returning a ToolResult with explicit meta (both spellings, since hosts read nested-then-flat while others read flat only):

    return ToolResult(
        content=...,
        meta={"ui": {"resourceUri": URI}, "ui/resourceUri": URI},
    )

    Separately, resources/read returns contents[0]._meta: null unless meta= 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 domain is not the gate.


    Net result

    After all three changes, verified on the wire:

    • ping → {} sessionlessly
    • tools/call result carries ui.resourceUri and ui/resourceUri
    • resources/read serves text/html;profile=mcp-app with _meta.ui.domain and _meta.ui.csp
    • tool definition carries _meta.ui.resourceUri; structuredContent present

    Claude 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 AppConfig to any tool whose text output has value on its own.

    Happy to supply full wire captures from the Python stack if useful.

  21. landonasbury commented on Sep 9, 2026

    @landonasbury

    hit the identical symptom

  22. edipox commented on Sep 18, 2026

    @edipox

    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.

    1. Return capabilities.extensions["io.modelcontextprotocol/ui"] on initialize. If you build a server instance per request on a stateless transport, oninitialized won'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.

    2. Put _meta.ui.resourceUri on the tools/call result, not just the tools/list entry. Some frameworks only decorate the tool definition. Send both spellings, _meta.ui.resourceUri and _meta["ui/resourceUri"].

    3. Return _meta.ui on the resources/read content item too, with csp — empty arrays if your view is self-contained. Only add domain if an external API needs a stable origin to allowlist. Getting it wrong blocks mounting; leaving it out doesn't.

    4. 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.

    5. Send ui/notifications/initialized unconditionally, with a short timeout as backup. Don't wait until you've recognised a particular ui/initialize response 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.

    6. 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.

    7. 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 get Unable 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 reach causes apart

    A dead cached URI and a wrong _meta.ui.domain look 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.

    resources/read  ui://app/view-<hash>.html   OK
    resources/read  ui://app/view.html          ERROR
    

    A wrong domain won'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 result or an error. Don't go by what the model says it got. Hosts strip _meta before the model ever sees it, so "there was no _meta on the result" tells you nothing about what you actually sent.

  23. theoldfather commented on Oct 6, 2026

    @theoldfather

    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.html with MIME type text/html;profile=mcp-app. The shell loads the renderer from jsDelivr, and _meta.ui.csp.resourceDomains declares that domain on both the resources/list entry and the resources/read content 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_url from the server constructor, which sets serverInfo.websiteUrl in 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:

    • The tool results were valid strict JSON, with no NaN values, odd characters or template syntax.
    • Result size was 40–70 KB, below the documented ~150,000 characters.
    • The renderer's CDN assets returned 200 with open CORS.
    • Dependency versions were pinned by a lockfile and unchanged between the releases.
    • The CSP metadata was present on the read item.

    Caveats: This is a small before/after sample, so we haven't isolated whether websiteUrl alone 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 saying websiteUrl affects rendering.

    Question: Does claude.ai read serverInfo.websiteUrl (or icons) 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 removing websiteUrl from your server info.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions