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

> Source: <https://dev.to/booyaka101/debugging-a-black-box-36-renders-against-claude-and-the-part-where-my-own-data-was-wrong-43ag>
> Published: 2026-07-31 15:37:38+00:00

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

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

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

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>
```

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.

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

``` js
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" },
];
```

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.

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.

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
```

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

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:

`resources/read`

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.

This is the part I'd want to read.

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)
```

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.

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
```

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.

Practical bits, valid as of late July 2026:

`_meta.ui.domain`

unless you need a stable origin to allowlist. If you do set it, hash the endpoint URL `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.I packaged the diagnostics into [ mcp-app-debug](https://github.com/Booyaka101/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.
