Shipping UI into a host you do not control.
The moment your tool server returns an interface instead of text, you have taken on a deployment you cannot deploy: a host fetches your markup when it likes, may cache it, builds the security policy from a declaration you wrote weeks earlier, and renders it in a sandbox where your error reporter cannot reach the network. The operational consequence is fixed by the specification and most teams read past it — the text result is the contract and the interface is an enhancement, so the thing you monitor, alert on and roll back is the tool, and the widget is a version string inside it.
Step 1You are not deploying a page. You are publishing an artefact somebody else fetches.
In the MCP Apps extension, a tool points at a UI resource with _meta.ui.resourceUri; the resource MUST use the ui:// scheme with MIME type text/html;profile=mcp-app, and the host MUST read it with resources/read. The spec also says the host MAY prefetch and cache that content, and may preload templates before the tool ever runs.
Read those two sentences together and the operational shape appears. There is no cache-invalidation primitive. No purge call, no ETag discipline the spec obliges a host to honour, no way to tell a host that the markup it holds is now wrong. Which means the thing you can change and the thing a user sees are separated by a cache whose policy is a per-host implementation detail — and the only handle you have on it is the URI.
# fragile: the host may be holding last week's markup ui://acme/invoice-table # operable: the URI is the version, so a rollout is a pointer change ui://acme/invoice-table@2026-11-02.3 # old URIs stay resolvable; a stale cache serves a # consistent old view instead of a broken new one
- Put a build identifier in the resource URI and change it on every markup change. The tool's
_metais what moves; the resource itself is immutable once published. - Keep the previous two or three URIs resolvable. A host holding a cached template will fetch the resource it remembers, and you want that fetch to succeed.
- Make rollback a pointer change, not a redeploy — the same discipline as rollout and versioning, with the extra constraint that you cannot drain the old version.
- Never put secrets or tenant data in the markup. It is a cacheable static artefact in somebody else's store. Data arrives separately, in the tool result.
Step 2The text result is the contract. Monitor that.
This is the sentence to put in your runbook: tools MUST return a meaningful content array even when UI is available, and servers SHOULD provide text-only fallback behaviour for every UI-enabled tool. The extension itself is optional and negotiated under io.modelcontextprotocol/ui, with clients declaring supported mimeTypes; servers SHOULD check client capabilities before registering UI-enabled tools. A host that has not adopted the extension treats your tool as an ordinary tool and nothing breaks.
So the interface is not your product surface. It is a presentation layer over a tool whose contract is text, and that inverts the monitoring you were about to build. Your availability target belongs on the tool call. Your correctness evals run against the content array. The widget gets a much weaker guarantee — rendered, or not — because in a meaningful share of sessions it will not render at all and the task must still complete.
The failure this prevents is specific and common: a team builds the widget first, lets the text result degrade into "See the table above.", and ships a tool that is useless in every host without the extension — and useless to the model even in hosts that have it, because the model reads the content array, not your pixels. A text result that only makes sense alongside the UI is a broken tool with a nice screenshot.
Step 3Your telemetry is blocked by default, and that is the right default.
Hosts render MCP Apps in a sandboxed iframe under a deny-by-default content policy. If you omit ui.csp entirely the host applies default-src 'none' with connect-src 'none'. An empty or absent connectDomains means no external connections; an empty frameDomains means no nested iframes; baseUriDomains defaults to base-uri 'self'. The host MUST build the policy from what you declared, MAY restrict further, and MUST NOT allow a domain you did not declare. For a web host, your view also runs through a sandbox proxy on a different origin from the host.
Three consequences for operations, in order of how much time they will cost you:
- Inline the bundle. Every origin counts, including wherever your JavaScript and CSS are served from. A single remote
<script src>you forgot to declare is a blank widget in a host that reports nothing to you. - Report through the tool, not through a beacon. The view can call
tools/call, proxied by the host to your server. That path already exists, already works under the default policy, and already carries your auth. An analytics domain inconnectDomainsis a line item in somebody's host security review, and it is the first one they will ask you to remove. - Assume you will never see a client-side exception. No error-reporting SDK will phone home. Your observability is the tool-call stream: emit a structured
view_errortool call with the build identifier from Step 1, and treat a build whose error calls spike as a rollback candidate. Everything in tracing and observability still applies; the span just has to start on your side of the proxy.
Step 4Capability is a per-host fact, so your rollout unit is host × version.
At the extension's announcement in January 2026 the supporting hosts were Claude on web and desktop, Goose, VS Code Insiders, and ChatGPT rolling out that week. The ext-apps repository is candid that it ships no production host implementation beyond a basic example, and several hosts implement the extension via the MCP-UI client SDK. On the wire, SDK versioning is deliberately forgiving — the protocol is unchanged between ext-apps 1.x and 2.x, a 2.x view works in a 1.x host and a 2.x host renders 1.x views — which is a gift and also the reason a host-specific bug will not announce itself as a version mismatch.
Treat it the way you would treat browsers in 2010, because operationally that is what it is.
- Keep an explicit host matrix — host, version where you can get it, renders / renders with defects / falls back to text — and date every row. It is the same artefact as the vendor matrix in third-party tool drift, and it decays just as fast.
- Negotiate, do not sniff. Check declared capabilities and
mimeTypesbefore registering UI-enabled tools; branch on the declaration rather than on a host name string. Capability discovery is the pattern and this extension is an instance of it. - Expect silent asymmetry. The spec's sandbox handshake notifications are MUST in the
2026-01-26version and SHOULD in the draft. A host targeting the draft can behave differently from one targeting the stable spec without either being wrong, which is exactly the class of change protocol revisions and deprecation windows exists to manage. - One host's outage is not your outage. If a host stops rendering, your tool still returns text and your task still completes — provided you did Step 2. That is the whole value of having done it.
Step 5Use the lifecycle notifications as the only clock you have.
The handshake is ui/initialize then ui/notifications/initialized, and the host MUST NOT send the view any request or notification before that notification arrives. Then ui/notifications/tool-input arrives at most once and is required before ui/notifications/tool-result; tool-input-partial MAY arrive zero or more times during streaming and the view MUST NOT rely on partial arguments for critical operations; tool-cancelled arrives if execution is cancelled; and the host MUST send ui/resource-teardown before tearing the resource down, and SHOULD wait for a response.
Those are your lifecycle hooks and each one maps to an operational rule.
- Flush on teardown. It is the one point at which you are told the view is ending and the host is asked to wait for you. Anything you want recorded — a draft, a duration, an unsent error — goes out there, as a tool call.
- Never act on a partial. The spec forbids relying on partial arguments for critical operations, which in practice means: no writes, no charges, no navigation on a streamed argument. The same rule as gating side effects on a finalised transcript in a voice stack.
- Handle cancellation as a normal path. A cancelled tool leaves a view holding an input and no result. Decide what that view shows, or your users will find out for you.
- Report size, do not assume it. The view SHOULD send
ui/notifications/size-changedon content size changes and hosts MUST listen when dimensions are flexible. A widget that never reports its size is a widget cropped differently in every host.
Step 6Treat the model's context as a write you version, because it overwrites.
The view can tell the model what happened in it, via ui/update-model-context. The semantics are last-write-wins: each request overwrites the previous context sent by that view, and if several arrive before the next user message the host SHOULD send only the last. The host also proxies tools/call from the view and MUST reject calls to tools whose visibility does not include "app"; tools whose visibility excludes "model" MUST NOT appear in the agent's tool list at all.
Two things fall out of that, and the first is a production incident waiting to happen. Because context updates overwrite rather than append, a view that writes partial state on every keystroke and then stops will leave the model holding whatever it happened to write last. The model will then describe the form confidently and wrongly. Make each update a complete, self-describing snapshot of the view's state, never a delta, and write it at the moments that matter — on commit, on teardown — rather than continuously.
The second is a design affordance worth using. App-only tools are the right home for anything the interface needs and the model should not be able to trigger on its own: a pagination fetch, a preview render, a validation call. Keeping those out of the model's tool list reduces both the token cost of your catalogue and the ways an agent can surprise you, which is the same argument made in MCP tool design about not exposing a tool just because it exists.
Run the stranger's-host drill before you ship. Disable the UI extension in your own client and use the tool for a day with text results only — if the task is harder than it was, Step 2 is not done. Then set ui.csp to nothing, load the widget, and confirm it still renders and still reports errors; if it goes blank, you have a remote dependency you did not declare. Both take an afternoon and both failures are otherwise discovered by a user in a host you have never run.