from line 1
11# Supersede older widget instances
22
3> Keep only the newest copy of a widget active when its tool is called more than once in a conversation
3> Keep only the newest copy of an MCP App widget active when Claude calls its tool more than once in a conversation, using a server key and BroadcastChannel.
44
5Each time Claude calls a tool that renders an MCP App, a separate iframe is mounted in the conversation. There is no host API to unmount earlier instances when a newer one appears, so by default you end up with several live copies of the same widget, each independently pushing [model-context updates](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#updatemodelcontext) (data the widget feeds into Claude's context for the next turn) and [messages](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage) to Claude.
5Each time Claude calls a tool that renders an MCP App, Claude mounts a separate iframe in the conversation. No host API unmounts earlier instances when a newer one appears, so by default several live copies of the same widget stay in the conversation. Each copy independently pushes [model-context updates](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#updatemodelcontext), the data a widget feeds into Claude's context for the next turn, and [messages](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage) to Claude.
66
7If your widget represents a single piece of state, such as a shopping cart or a dashboard, only the most recent instance should remain interactive. You can use [`BroadcastChannel`](https://developer.mozilla.org/docs/Web/API/BroadcastChannel) to make earlier instances disable themselves.
7This page is for MCP App developers whose widget represents a single piece of state, such as a shopping cart or a dashboard, where only the most recent instance should remain interactive. It shows how to use [`BroadcastChannel`](https://developer.mozilla.org/docs/Web/API/BroadcastChannel) so earlier instances disable themselves: [mint an election key on the server](#mint-the-election-key-on-the-server), [run the election in the widget](#run-the-election-in-the-widget), then [handle the production edge cases](#handle-production-edge-cases).
88
9The snippets on this page assume you have registered a UI resource and tool and created an `App` instance from `@modelcontextprotocol/ext-apps`. See the [SDK Quickstart](https://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html) if you haven't.
9The snippets assume you have registered a UI resource and tool and created an `App` instance from `@modelcontextprotocol/ext-apps`. If you haven't, start with the [SDK Quickstart](https://modelcontextprotocol.github.io/ext-apps/api/documents/Quickstart.html).
1010
11## How it works
11## Understand how supersession works
1212
13All widget iframes from a single connector are served from the same sandbox origin on `*.claudemcpcontent.com` (the iframe sandbox includes [`allow-same-origin`](https://developer.mozilla.org/docs/Web/HTML/Element/iframe#sandbox)). That means a `BroadcastChannel` opened in one instance reaches every other instance from the same connector in the current conversation. See [Channel scope and `ui.domain`](#channel-scope-and-ui-domain) for how a fixed domain widens this.
13Claude serves all widget iframes from a single connector from the same sandbox origin on `*.claudemcpcontent.com`, and the iframe sandbox includes [`allow-same-origin`](https://developer.mozilla.org/docs/Web/HTML/Element/iframe#sandbox). A `BroadcastChannel` opened in one instance therefore reaches every other instance from the same connector in the current conversation. A fixed `ui.domain` widens that scope, as [Channel scope and `ui.domain`](#channel-scope-and-ui-domain) explains.
1414
15The pattern has three parts:
15The supersession pattern uses that shared `BroadcastChannel` to elect the newest instance:
1616
171. **The server stamps each tool result with an election key.** It returns a `{createdAt, seq}` pair (server wall-clock time and a monotonic counter) in [`structuredContent`](https://modelcontextprotocol.io/specification/latest/server/tools#structured-content), the typed JSON payload slot of an MCP tool result. Tool results are stored in the conversation transcript, so every device and every remount of the widget sees the same key.
182. **Each widget announces its key on a shared channel.** Shortly after `connect()` resolves, the host delivers the tool result that mounted this widget (including its `structuredContent`) via the SDK's [`toolresult` event](https://modelcontextprotocol.github.io/ext-apps/api/types/app.AppEventMap.html). The widget reads its key from that event, opens a `BroadcastChannel`, and broadcasts the key.
193. **Any widget that sees a younger sibling marks itself superseded.** It greys out its UI, disables its buttons, and short-circuits all calls that mutate model context or inject messages.
171. **The server stamps each tool result with an election key**: it returns a `{createdAt, seq}` pair in [`structuredContent`](https://modelcontextprotocol.io/specification/latest/server/tools#structured-content), the typed JSON payload slot of an MCP tool result. `createdAt` is server wall-clock time and `seq` is a monotonic counter. Tool results are stored in the conversation transcript, so every device and every remount of the widget sees the same key.
182. **Each widget announces its key on a shared channel**: shortly after `connect()` resolves, the host delivers the tool result that mounted this widget, including its `structuredContent`, through the SDK's [`toolresult` event](https://modelcontextprotocol.github.io/ext-apps/api/types/app.AppEventMap.html). The widget reads its key from that event, opens a `BroadcastChannel`, and broadcasts the key.
193. **Any widget that sees a younger sibling marks itself superseded**: it greys out its UI, disables its buttons, and short-circuits all calls that mutate model context or inject messages.
2020
2121## Mint the election key on the server
2222
23Use [`registerAppTool`](https://modelcontextprotocol.github.io/ext-apps/api/functions/server-helpers.registerAppTool.html) to register the tool, and return the key in `structuredContent` alongside your normal tool output. A per-process counter works for a demo; a production server should derive the key from something durable, such as a database row ID or a version number on the underlying record.
23Use [`registerAppTool`](https://modelcontextprotocol.github.io/ext-apps/api/functions/server-helpers.registerAppTool.html) to register the tool, and return the key in `structuredContent` alongside your normal tool output. A per-process counter works for a demo. A production server should derive the key from something durable, such as a database row ID or a version number on the underlying record. This example registers a `show_cart` tool that returns the key with the cart contents:
2424
2525```ts theme={null}
2626import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
from line 51
5151);
5252```
5353
54### Why not use client-side `Date.now()`?
54### Understand why the key comes from the server
5555
56Client mount time does not reflect tool-call order. When a stored conversation is reopened, Claude lazy-mounts widget cells as they scroll into view, so an older widget can mount after a newer one and would win an election based on client timestamps. The server-minted key is written into the transcript at tool-call time and is identical everywhere.
56Client mount time doesn't reflect tool-call order. When a user reopens a stored conversation, Claude lazy-mounts widget cells as they scroll into view, so an older widget can mount after a newer one and would rank as newest in an election based on client timestamps. The server-minted key is written into the transcript at tool-call time and is identical everywhere.
5757
5858## Run the election in the widget
5959
60The four snippets in this section form a single module; paste them in order into your widget entry file.
60The snippets in this section form a single module. Paste them in order into your widget entry file.
6161
6262### Read the key from the `toolresult` event
6363
64Connect and read the values you need from the host: your instance ID from [`hostContext.toolInfo`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiHostContext.html#toolinfo), and the server-minted key from the `toolresult` event. The event's `structuredContent` is typed `Record<string, unknown>`, so cast it to the shape your server returns.
64Connect and read the values you need from the host: your instance ID from [`hostContext.toolInfo`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiHostContext.html#toolinfo), and the server-minted key from the `toolresult` event. The event's `structuredContent` is typed `Record<string, unknown>`, so cast it to the shape your server returns:
6565
6666```ts theme={null}
6767import { App } from "@modelcontextprotocol/ext-apps";
from line 94
9494
9595### Broadcast and compare on a shared channel
9696
97Broadcast the key and compare against every sibling you hear from. The comparison is `createdAt`, tie-broken by `seq`, then by instance ID for determinism. Ignore inbound messages until your own key is finalized so you never reply with an undefined key.
97Broadcast the key and compare against every sibling you hear from. The comparison is `createdAt`, tie-broken by `seq`, then by instance ID for determinism. Ignore inbound messages until your own key is finalized so you never reply with an undefined key:
9898
9999```ts theme={null}
100100const channel = new BroadcastChannel("my-app-cart-supersede");
from line 164
164164}
165165```
166166
167## Special considerations
167## Handle production edge cases
168168
169The election above covers the common case. A production widget should also handle the following.
169The election in [Run the election in the widget](#run-the-election-in-the-widget) covers the common case. A production widget also accounts for channel scope under a fixed `ui.domain`, a server key that arrives late, caching the key across remounts, and the requests a custom `postMessage` bridge would drop.
170170
171171### Channel scope and `ui.domain`
172172
173`BroadcastChannel` is same-origin only. How far that origin extends depends on whether you set [`_meta.ui.domain`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceMeta.html#domain) on your resource:
173`BroadcastChannel` is same-origin only, and whether you set [`_meta.ui.domain`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceMeta.html#domain) on your resource decides how far that origin extends:
174174
175* **Without `ui.domain`** (the default), Claude derives the iframe origin from the conversation and connector, so the broadcast is scoped to a single conversation.
176* **With a fixed `ui.domain`**, the origin is shared across every conversation and tab for your connector. A fixed channel name would let a widget in one conversation supersede a widget in another. Neither `hostContext` nor the tool-call arguments include a Claude-provided conversation ID, so if you need both a fixed domain and per-conversation elections, generate your own scope key on the server (for example, a UUID minted once per client connection) and return it in `structuredContent` for the widget to append to the channel name.
175* **Without `ui.domain`, the default**: Claude derives the iframe origin from the conversation and connector, so the broadcast is scoped to a single conversation
176* **With a fixed `ui.domain`**: the origin is shared across every conversation and tab for your connector, so a fixed channel name would let a widget in one conversation supersede a widget in another
177177
178Neither `hostContext` nor the tool-call arguments include a Claude-provided conversation ID. If you need both a fixed domain and per-conversation elections, generate your own scope key on the server, such as a UUID minted once per client connection, and return it in `structuredContent` for the widget to append to the channel name.
179
178180### Fall back if the server key is delayed
179181
180The main snippet above waits for the `toolresult` event before announcing. If you want the widget to participate in the election even when that event is slow to arrive, replace that listener with one that resolves a promise, and race the promise against a short timeout after `connect()`:
182The widget's `toolresult` listener in [Run the election in the widget](#run-the-election-in-the-widget) waits for the event before announcing. If you want the widget to participate in the election even when that event is slow to arrive, replace that listener with one that resolves a promise, and race the promise against a short timeout after `connect()`:
181183
182184```ts theme={null}
183185let resolveServerKey!: (k: { orderKey: number; seq?: number }) => void;
from line 210
208210
209211If the server key arrives after the timeout, adopt it, recompute `superseded` against the peers you have already heard from, and re-announce so siblings update their view of you. The recomputed result may flip the instance back to live.
210212
211### Fallback caveat: don't compare server and client timestamps
213### Don't compare server and client timestamps
212214
213This applies only if you implemented the fallback above. If you fall back to a client-side `Date.now()` while waiting for the server key, tag the key with its source and refuse to compare a client value against a server value. A server `createdAt` from a tool call made hours ago will always be smaller than a fresh client timestamp, which would wrongly hand "live" to whichever instance happened to fall back. Include `keySource` in the broadcast payload (`announce()` and the `born` reply) and in the `peers` Map value type so siblings can read it:
215If you implemented the delayed-key fallback and fall back to a client-side `Date.now()` while waiting for the server key, tag the key with its source and refuse to compare a client value against a server value. A server `createdAt` from a tool call made hours ago is always smaller than a fresh client timestamp, which would wrongly mark whichever instance happened to fall back as live. Include `keySource` in the broadcast payload, in both `announce()` and the `born` reply, and in the `peers` Map value type so siblings can read it:
214216
215217```ts theme={null}
216218type KeySource = "server" | "client";
from line 227
225227}
226228```
227229
228### Caching the key across remounts
230### Cache the key across remounts
229231
230On Claude.ai web, [`hostContext.toolInfo.id`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiHostContext.html#toolinfo) is the stable tool-use ID, so you can persist the resolved server key to `localStorage` keyed by that ID and reuse it on the next mount without waiting for the `toolresult` event again.
232On claude.ai on the web, [`hostContext.toolInfo.id`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiHostContext.html#toolinfo) is the stable tool-use ID, so you can persist the resolved server key to `localStorage` keyed by that ID and reuse it on the next mount without waiting for the `toolresult` event again.
231233
232Treat this as an optimization rather than a correctness guarantee. On Claude iOS, `toolInfo.id` is `undefined` when a stored conversation is rehydrated, so there is no stable per-instance cache key. Detect that case and skip the cache; the server key from the `toolresult` event is the only ordering source that works on every platform.
234Treat the `localStorage` cache as an optimization rather than a correctness guarantee. On Claude iOS, `toolInfo.id` is `undefined` when a stored conversation is rehydrated, so there is no stable per-instance cache key. Detect that case and skip the cache; the server key from the `toolresult` event is the only ordering source that works on every platform.
233235
234236### If you bypass the SDK `App` class
235237
236The snippets on this page use the SDK's [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class. If you instead hand-roll a minimal `postMessage` bridge, it will silently drop requests sent from the host to the widget, such as `ping` (a liveness check) and [`ui/resource-teardown`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceTeardownRequest.html) (the host asking the widget to clean up before unmount). Claude.ai web does not currently send either to widgets, and Claude iOS sends `ui/resource-teardown` only when the user navigates away from the conversation, so ignoring them is harmless today. The `App` class handles the full request surface and is recommended for production.
238The snippets on this page use the SDK's [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) class, which handles the full host request surface and is recommended for production. A minimal `postMessage` bridge you write yourself silently drops requests the host sends to the widget, such as `ping`, a liveness check, and [`ui/resource-teardown`](https://apps.extensions.modelcontextprotocol.io/api/interfaces/app.McpUiResourceTeardownRequest.html), the host's request that the widget clean up before unmount. claude.ai on the web doesn't send either to widgets, and Claude iOS sends `ui/resource-teardown` only when the user navigates away from the conversation, so a bridge that ignores them loses nothing on those hosts.
237239
238## Related topics
240## Next steps
239241
240* [Cross-platform compatibility](/docs/connectors/building/mcp-apps/cross-compatibility#domain-handling) for how `_meta.ui.domain` is computed on Claude.
241* [SDK API reference](https://modelcontextprotocol.github.io/ext-apps/api/index.html) for `registerAppTool`, `App`, and `McpUiResourceMeta`.
242* [Set `ui.domain` for Claude](/docs/connectors/building/mcp-apps/getting-started#set-ui-domain-for-claude): how to compute `_meta.ui.domain` for Claude
243* [Troubleshoot MCP Apps](/docs/connectors/building/mcp-apps/troubleshooting): developer tools and fixes when a widget doesn't render
244* [SDK API reference](https://modelcontextprotocol.github.io/ext-apps/api/index.html): `registerAppTool`, `App`, and `McpUiResourceMeta`
242245
No line in this hunk matches that.