Skip to content

fix(client): sandbox proxy speaks the current bridge protocol - #214

Open
witt3rd wants to merge 3 commits into
MCP-UI-Org:mainfrom
witt3rd:fix/sandbox-proxy-protocol-drift
Open

fix(client): sandbox proxy speaks the current bridge protocol#214
witt3rd wants to merge 3 commits into
MCP-UI-Org:mainfrom
witt3rd:fix/sandbox-proxy-protocol-drift

Conversation

@witt3rd

@witt3rd witt3rd commented Jul 17, 2026

Copy link
Copy Markdown

Fixes #213.

Problem

The reference sandbox proxy (sdks/typescript/client/scripts/proxy/index.html) uses a pre-1.7 postMessage protocol that no longer matches the @modelcontextprotocol/ext-apps bridge that @mcp-ui/client depends on. A host that copies this proxy (the documented integration path) gets a ready-handshake timeout → blank card, because the bridge never sees the ready signal it waits for.

What the proxy posted:

window.parent.postMessage({ type: 'ui-proxy-iframe-ready' }, '*');   // ready
// ...and listened for:
event.data.type === 'ui-html-content'                                // html

What the bridge waits for (setupSandboxProxyIframe / AppBridge.sendSandboxResourceReady, verified against ext-apps@1.7.4):

event.data.method === SANDBOX_PROXY_READY_METHOD   // 'ui/notifications/sandbox-proxy-ready'
// html delivered as a JSON-RPC notification:
{ method: 'ui/notifications/sandbox-resource-ready', params: { html, csp } }

Different field (type vs method) and different strings, so onReady never resolves, AppFrame times out at DEFAULT_SANDBOX_TIMEOUT_MS, and no HTML is ever sent.

Fix

scripts/proxy/index.html, rawhtml mode only (the external-url branch is untouched):

  • Emit { method: 'ui/notifications/sandbox-proxy-ready' } as the ready signal.
  • Read HTML from event.data.params.html on the ui/notifications/sandbox-resource-ready notification.

The double-iframe structure, document.write rendering, and bidirectional relay are unchanged — only the three stale protocol strings are corrected.

Test hardening

ProxyScript.test.ts previously asserted the proxy's own stale strings, which is why it stayed green while the proxy drifted from the bridge. It now:

  • imports the real SANDBOX_PROXY_READY_METHOD from @modelcontextprotocol/ext-apps/app-bridge (so it can't drift from the SDK again), and
  • sends HTML in the bridge's actual notification shape ({ method, params: { html } }).

Verification: full @mcp-ui/client suite — 74/74 passing (vitest run).

Note

Filed from a fork; happy to adjust the shape to your preferences. If you'd also like the proxy shipped as a package export (so integrators reference a version-matched file rather than copying a scripts/ artifact), I can add that in a follow-up.

witt3rd added 2 commits July 16, 2026 20:33
The reference sandbox proxy (scripts/proxy/index.html) used a pre-1.7
postMessage protocol that no longer matches the @modelcontextprotocol/ext-apps
bridge @mcp-ui/client depends on, so any host copying it got a ready-handshake
timeout and a blank card.

The proxy posted { type: 'ui-proxy-iframe-ready' } and listened for
{ type: 'ui-html-content' }, while the bridge (setupSandboxProxyIframe /
AppBridge.sendSandboxResourceReady) waits for a message whose .method equals
SANDBOX_PROXY_READY_METHOD ('ui/notifications/sandbox-proxy-ready') and delivers
HTML as the JSON-RPC notification
{ method: 'ui/notifications/sandbox-resource-ready', params: { html, csp } }.
Different field (type vs method) and different strings → onReady never resolves,
AppFrame times out, no HTML is ever sent.

Fix (rawhtml mode only; external-url branch untouched):
- emit { method: 'ui/notifications/sandbox-proxy-ready' } as the ready signal
- read HTML from event.data.params.html on the
  'ui/notifications/sandbox-resource-ready' notification

ProxyScript.test.ts now imports the real SANDBOX_PROXY_READY_METHOD constant
from @modelcontextprotocol/ext-apps/app-bridge and sends HTML in the bridge's
notification shape, so it fails if the proxy ever drifts from the SDK's wire
format again. Full client suite: 74/74 passing.
…ame PR)

The protocol-string fix was necessary but not sufficient: the proxy still gated
raw-HTML mode behind ?contentType=rawhtml, but the host component (AppFrame /
buildSandboxUrl) sets NEITHER contentType NOR url — it delivers HTML via
postMessage (sendSandboxResourceReady). So the gate never fired: the proxy fell
through to 'Error: missing url or html parameter', never posted the ready
signal, and AppFrame timed out with a blank card ('timed out waiting for sandbox
proxy iframe to render'). Confirmed: no production caller in the SDK sets either
query param (three empty greps across components/ + utils/).

Fix: raw-HTML is now the DEFAULT mode, taken whenever there is no explicit ?url=
target. The external-URL path is the opt-in (an actual target present).
?contentType=rawhtml is still honored for callers that set it, but is no longer
required — matching how AppFrame actually drives the proxy.

ProxyScript.test.ts gains a regression guard: loads the proxy with NO query
params (exactly how AppFrame calls it) and asserts it enters raw-HTML mode
(posts ready + creates iframe#root). Proven both directions: the test FAILS
against the pre-fix proxy and PASSES with the fix. Full client suite: 75/75.

Verified end-to-end in a live consumer (spire-ui): the fixed proxy served
statically, AppRenderer renders real specialist HTML in the sandboxed iframe —
the card paints (human-confirmed in-browser), where before it was blank.
@forge-witt3rd

Copy link
Copy Markdown

Second drift found + fixed in this PR (611960f). The protocol-string fix was necessary but not sufficient — a live consumer (spire-ui rendering via AppRenderer) still got a blank card + timed out waiting for sandbox proxy iframe to render.

Root: the proxy gated raw-HTML mode behind ?contentType=rawhtml, but the host component never sets it. AppFramebuildSandboxUrl sets neither contentType nor url — it delivers HTML via postMessage (sendSandboxResourceReady). So the gate never fired; the proxy fell through to Error: missing url or html parameter, never posted the ready signal, and AppFrame timed out. Confirmed no production caller sets either query param (grep across components/ + utils/ — empty).

Fix: raw-HTML is now the default mode (taken when there's no explicit ?url= target); external-URL is the opt-in. ?contentType=rawhtml still honored, no longer required — matching how AppFrame actually drives the proxy.

Regression guard: ProxyScript.test.ts gains a test that loads the proxy with no query params (as AppFrame does) and asserts it enters raw-HTML mode. Proven both directions — fails against the pre-fix proxy, passes with the fix. Full client suite: 75/75.

Verified in a live browser: fixed proxy served by spire-ui, AppRenderer renders real specialist HTML in the sandboxed iframe — the card paints where it was blank before. This PR now carries both drifts: the wire-format protocol strings and the mode-default gating.

Review of a downstream consumer (spire-ui PR) flagged a pre-existing issue in
the reference proxy: messages relayed from the guest iframe back to the parent
used targetOrigin='*', so a malicious site that iframes /sandbox_proxy.html
could receive relayed guest messages under the embedder's context.

Derive hostOrigin once from document.referrer (the embedding parent's URL),
fall back to '*' only when referrer is stripped, and use it as the targetOrigin
for both parent-directed relays (raw-HTML mode + external-URL mode). The
stateless ready handshake stays '*': it carries no data, and the host origin
isn't reliably knowable at signal time.

Note: the external-URL relay is scoped to hostOrigin (the parent is the host
app), NOT the guest's urlOrigin — posting guest→parent with the guest's origin
would be incorrect. Full client suite: 75/75.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reference sandbox proxy (scripts/proxy/index.html) uses stale postMessage protocol → AppFrame timeout / blank card

2 participants