DEV Community

Christo
Christo

Posted on

Debugging a black box: 36 renders against Claude, and the part where my own data was wrong

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

There's a long issue 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.

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

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.

The finding, up front

The most-upvoted lead in that thread says claude.ai silently refuses to place the iframe unless your resource declares _meta.ui.domain, computed as sha256(<your endpoint URL>)[:32] + ".claudemcpcontent.com".

Here's what varying that one field actually does:

_meta.ui.domain iframe mounted sandbox origin
computed value 10/10 one stable origin, every render
absent 10/10 host default — differs per conversation
present but wrong 0/8 never created

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

But 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 vs https. So the advice inverts the risk: follow it imprecisely and you convert a working app into a broken one.

The original comment wasn't wrong about everything — wrong values failing, and stale ui:// URIs 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.

How to A/B a host you can't debug

Four things made this measurable. None are MCP-specific.

1. Give yourself a signal that needs no JavaScript

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

So every widget got this at the top:

<div id="marker" style="background:#1f7a4d;color:#fff;padding:18px 20px;font-weight:700">
  STATIC MARKER — arm: with_domain
  <small>this block needs no JavaScript</small>
</div>
Enter fullscreen mode Exit fullscreen mode

A plain styled block. No script, no postMessage, no handshake. If the document renders in a frame at all, that bar is visible.

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

2. Vary exactly one thing

One server, one endpoint, several tools that are byte-identical except for the single field under test:

const ARMS = [
  { id: "with_domain", tool: "probe_with_domain", domain: "computed" },
  { id: "no_domain",   tool: "probe_no_domain",   domain: null },
  { id: "bad_domain",  tool: "probe_bad_domain",  domain: "wrong-value.example.com" },
];
Enter fullscreen mode Exit fullscreen mode

Everything else is held constant and deliberately maximal: echoed protocolVersion, both the nested and legacy _meta resource-URI keys, the exact text/html;profile=mcp-app mime type, permissive CORS, zero external imports. If an arm behaves differently, there is one candidate cause.

3. Establish a control in a host you can see

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

4. Build a proxy measurement, then calibrate it against reality

Checking 36 renders by eye is how you end up with three data points and a vibe. The widget calls size-changed on load, so a painted card settles taller than the reserved placeholder — meaning iframe height is a usable proxy for "did it paint":

painted: (frame?.height ?? 0) > 150   // 150 = reserved-and-blank
Enter fullscreen mode Exit fullscreen mode

Crucially, 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.

A proxy you haven't calibrated is a guess with a number attached.

What it found

The ui.domain result 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.

Two facts make that host-side rather than mine:

  • Every blank render had a successful resources/read in my server log — 19 tool calls, 20 resource reads. The host fetched my HTML and then didn't display it.
  • The static marker didn't paint. No JavaScript involved. The document never rendered.

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

The two times my data lied

This is the part I'd want to read.

My 36 runs couldn't answer the question I used them to answer

I published that the default sandbox origin is minted "per render." I had 36 runs and a table. It was wrong.

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

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

render 1: 0497825c…claudemcpcontent.com
render 2: 0497825c…claudemcpcontent.com
render 3: 0497825c…claudemcpcontent.com
→ second conversation: bbed4340…  (different, stable within itself)
Enter fullscreen mode Exit fullscreen mode

Per-conversation. The spec was right and I'd contradicted it on the strength of data that couldn't see the difference.

Thirty-six runs felt like rigour. Sample size doesn't rescue a design that can't separate the hypotheses.

A check that passed because the page was empty

Tearing down afterwards, my cleanup script reported verified removed for a connector that was still there.

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

The fix is to require a precondition before believing a negative:

// don't accept "not found" from a page that never loaded
if (!(await openPanel())) {
  console.log("UNVERIFIED — panel would not open");
  process.exit(2);
}
const row = await findRow();   // only now does "not found" mean anything
Enter fullscreen mode Exit fullscreen mode

That immediately returned STILL PRESENT.

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

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

If you're hitting this

Practical bits, valid as of late July 2026:

  • Put a static, no-JS element in your widget. Permanently. It costs nothing and it's the difference between "didn't mount" and "mounted and died."
  • Don't set _meta.ui.domain unless you need a stable origin to allowlist. If you do set it, hash the endpoint URL exactly as the client connected.
  • Version your ui:// URIs 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.
  • Report host rendering bugs on the host's tracker. The spec/SDK repo can't fix a client renderer, which is why that thread has sat for months.

I packaged the diagnostics into mcp-app-debug (npx mcp-app-debug <server-url>) — it renders your app through the same sandbox path, logs every postMessage frame, and now flags a mismatched ui.domain and 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.

Top comments (0)