{"slug": "debugging-a-black-box-36-renders-against-claude-and-the-part-where-my-own-data", "title": "Debugging a black box: 36 renders against Claude, and the part where my own data was wrong", "summary": "An engineer built a probe server and ran 36 renders against claude.ai web to debug why MCP App widgets silently fail to render. The experiments showed that omitting the `_meta.ui.domain` field does not prevent rendering, but a wrong value is fatal, and that a static HTML marker is the key to distinguishing between a never-created iframe and a failed script. The findings invert common advice and highlight the importance of precise endpoint hashing.", "body_md": "If you've built an MCP App — the HTML widget an MCP server hands a host to render inline — you may have hit this: the tool call succeeds, `structuredContent`\n\ncomes back fine, the model announces that a widget rendered, and the user sees nothing. No error. No console output. Just a gap in the conversation.\n\nThere's a [long issue](https://github.com/modelcontextprotocol/ext-apps/issues/671) full of people with this exact symptom, all of them (me included) posting variations of \"my server is spec-correct and nothing renders.\" That's a hard thing to act on. So I built a probe server designed to answer one question at a time and ran it 36 times.\n\nThis post is mostly about *method* — how you experiment on a host whose source you can't read and whose renderer you can't attach a debugger to. The MCP specifics are the worked example. The most useful part is at the end, where my measurements lied to me twice.\n\n**Everything behavioural here was measured on 31 July 2026 against claude.ai web.** Host behaviour changes; treat the numbers as a snapshot, not a spec.\n\nThe most-upvoted lead in that thread says claude.ai silently refuses to place the iframe unless your resource declares `_meta.ui.domain`\n\n, computed as `sha256(<your endpoint URL>)[:32] + \".claudemcpcontent.com\"`\n\n.\n\nHere's what varying that one field actually does:\n\n`_meta.ui.domain` |\niframe mounted | sandbox origin |\n|---|---|---|\n| computed value | 10/10 | one stable origin, every render |\n| absent | 10/10 | host default — differs per conversation |\n| present but wrong | 0/8 |\nnever created |\n\nOmitting it doesn't stop anything. What it actually controls is origin *stability*, which is exactly what the SDK docs say it's for: a fixed origin your API server can allowlist for CORS.\n\nBut a **wrong** value is fatal. And the easy way to produce one is hashing an endpoint string that differs slightly from the URL the client connected with — a trailing slash, a missing path segment, `http`\n\nvs `https`\n\n. So the advice inverts the risk: follow it imprecisely and you convert a working app into a broken one.\n\nThe original comment wasn't wrong about everything — wrong values failing, and stale `ui://`\n\nURIs breaking after a rebundle, both hold. I hit the second one by accident mid-experiment. Writing any of it down was more than anyone else in that thread had done.\n\nFour things made this measurable. None are MCP-specific.\n\nThe central ambiguity was: when nothing appears, did the host never create the iframe, or did it create one that failed to run my code? Those have completely different causes and you cannot tell them apart from outside.\n\nSo every widget got this at the top:\n\n```\n<div id=\"marker\" style=\"background:#1f7a4d;color:#fff;padding:18px 20px;font-weight:700\">\n  STATIC MARKER — arm: with_domain\n  <small>this block needs no JavaScript</small>\n</div>\n```\n\nA plain styled block. No script, no postMessage, no handshake. If the document renders in a frame *at all*, that bar is visible.\n\nThis one move resolved the whole question. When cards came back blank, the marker was missing too — so the document never rendered, and every explanation involving my app's code, the handshake, or the SDK was dead on arrival. You can't reason your way to that from a screenshot of nothing.\n\nOne server, one endpoint, several tools that are byte-identical except for the single field under test:\n\n``` js\nconst ARMS = [\n  { id: \"with_domain\", tool: \"probe_with_domain\", domain: \"computed\" },\n  { id: \"no_domain\",   tool: \"probe_no_domain\",   domain: null },\n  { id: \"bad_domain\",  tool: \"probe_bad_domain\",  domain: \"wrong-value.example.com\" },\n];\n```\n\nEverything else is held constant and deliberately maximal: echoed `protocolVersion`\n\n, both the nested and legacy `_meta`\n\nresource-URI keys, the exact `text/html;profile=mcp-app`\n\nmime type, permissive CORS, zero external imports. If an arm behaves differently, there is one candidate cause.\n\nBefore touching the real target, I ran all arms through a local spec-conformant harness. All green, all identical. That matters: it converts \"my app is broken\" into \"my app is fine and the target host differs,\" which is the only way a finding about the host means anything.\n\nChecking 36 renders by eye is how you end up with three data points and a vibe. The widget calls `size-changed`\n\non load, so a painted card settles taller than the reserved placeholder — meaning iframe height is a usable proxy for \"did it paint\":\n\n```\npainted: (frame?.height ?? 0) > 150   // 150 = reserved-and-blank\n```\n\nCrucially, I calibrated it: screenshotted known-painted and known-blank runs and confirmed the heights before trusting it across the batch. When I later added a second widget template, it settled at a different height and my threshold silently misclassified two runs as blank. Caught it because the number looked odd and I went back to the screenshot.\n\n**A proxy you haven't calibrated is a guess with a number attached.**\n\nThe `ui.domain`\n\nresult above, plus the thing I actually care about: in one window, **6 of 10 renders mounted the iframe and never painted.** No error, no console output, model reporting success. In a later window, 0 of 18.\n\nTwo facts make that host-side rather than mine:\n\n`resources/read`\n\nI don't know the cause. The clean window began right after I disconnected and reconnected the connector, which is n=1 and I'm not going to dress it up as a fix. That's the honest state: reproduced, quantified, cause unknown.\n\nThis is the part I'd want to read.\n\nI published that the default sandbox origin is minted \"per render.\" I had 36 runs and a table. It was wrong.\n\nEvery run in that batch used a **fresh conversation**. So my ten distinct origins across ten runs were equally consistent with \"per render\" and \"per conversation\" — the design held the deciding variable constant. I had carefully varied one thing and then made a claim about a *different* thing.\n\nI only noticed because a merged PR on the spec repo used the phrase \"typically per-conversation\" and it didn't match what I'd written. The actual test takes ten minutes: three renders **inside one conversation**.\n\n```\nrender 1: 0497825c…claudemcpcontent.com\nrender 2: 0497825c…claudemcpcontent.com\nrender 3: 0497825c…claudemcpcontent.com\n→ second conversation: bbed4340…  (different, stable within itself)\n```\n\nPer-conversation. The spec was right and I'd contradicted it on the strength of data that couldn't see the difference.\n\nThirty-six runs *felt* like rigour. Sample size doesn't rescue a design that can't separate the hypotheses.\n\nTearing down afterwards, my cleanup script reported `verified removed`\n\nfor a connector that was still there.\n\nThe check navigated to a settings URL, then asserted the connector's name was absent from the page text. But it was already on that URL, so the navigation was a no-op, the settings panel never rendered, and the name was absent from a blank page. Absence of evidence, scored as success.\n\nThe fix is to require a precondition before believing a negative:\n\n```\n// don't accept \"not found\" from a page that never loaded\nif (!(await openPanel())) {\n  console.log(\"UNVERIFIED — panel would not open\");\n  process.exit(2);\n}\nconst row = await findRow();   // only now does \"not found\" mean anything\n```\n\nThat immediately returned `STILL PRESENT`\n\n.\n\nBoth mistakes are the same shape: **a check that couldn't distinguish the states it was being asked about, returning a clean-looking answer.** One dressed as a controlled experiment, one as a teardown assertion. Neither announced itself — both produced confident, plausible, wrong output, which is precisely why they're worth catching.\n\nThe habit I'd take from it: for any check that can pass, ask what *else* would make it pass. If \"the page was blank\" or \"I never varied that\" is on the list, the check isn't measuring what you think.\n\nPractical bits, valid as of late July 2026:\n\n`_meta.ui.domain`\n\nunless you need a stable origin to allowlist. If you do set it, hash the endpoint URL `ui://`\n\nURIs when you change the bundle, and keep serving the old ones — hosts cache the tool declaration independently and a vanished URI fails with the same generic error as a bad domain.I packaged the diagnostics into [ mcp-app-debug](https://github.com/Booyaka101/mcp-app-debug) (\n\n`npx mcp-app-debug <server-url>`\n\n) — it renders your app through the same sandbox path, logs every postMessage frame, and now flags a mismatched `ui.domain`\n\nand tells you which endpoint spelling you hashed by mistake. But the tool is incidental. The marker, the single variable, and the calibrated proxy are the parts that would've saved me the afternoon.", "url": "https://wpnews.pro/news/debugging-a-black-box-36-renders-against-claude-and-the-part-where-my-own-data", "canonical_source": "https://dev.to/booyaka101/debugging-a-black-box-36-renders-against-claude-and-the-part-where-my-own-data-was-wrong-43ag", "published_at": "2026-07-31 15:37:38+00:00", "updated_at": "2026-07-31 16:05:51.930844+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-infrastructure"], "entities": ["Claude", "MCP", "modelcontextprotocol/ext-apps"], "alternates": {"html": "https://wpnews.pro/news/debugging-a-black-box-36-renders-against-claude-and-the-part-where-my-own-data", "markdown": "https://wpnews.pro/news/debugging-a-black-box-36-renders-against-claude-and-the-part-where-my-own-data.md", "text": "https://wpnews.pro/news/debugging-a-black-box-36-renders-against-claude-and-the-part-where-my-own-data.txt", "jsonld": "https://wpnews.pro/news/debugging-a-black-box-36-renders-against-claude-and-the-part-where-my-own-data.jsonld"}}