Skip to content

feat: add ui/set-view-state for View state persistence - #844

Open
ochafik wants to merge 10 commits into
mainfrom
ochafik/view-state
Open

ochafik wants to merge 10 commits into
mainfrom
ochafik/view-state

Conversation

@ochafik

@ochafik ochafik commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Addresses #417 and #465.

Note

Draft proposal, opened for discussion. The open questions at the end are real ones.

Problem

A View loses its in-memory state whenever its iframe is destroyed: on page reload, when a conversation is reopened, or when the Host unmounts it. The Host re-sends the tool input and result, but not what the user did afterwards.

The documented pattern, localStorage keyed by a server-made viewUUID, depends on how each Host assigns sandbox origins (#213), cannot follow the user to another device, and is not deleted with the conversation.

It is also the main gap left for apps that come from the OpenAI Apps SDK (#465).

Prior art and credit

The idea is OpenAI's. In the OpenAI Apps SDK, window.openai.widgetState / setWidgetState() give a widget persistence that is scoped to one rendered instance, and its structured shape separates what the model sees from what only the UI sees. Credit to its authors: this PR brings the UI-only half to MCP Apps, under names that fit this spec.

OpenAI Apps SDK MCP Apps
window.openai.widgetState hostContext.viewState (this PR)
setWidgetState(state), or the privateContent of its structured shape ("UI-only state the model should not see") ui/set-view-state (this PR)
modelContent ("text or JSON the model should see") ui/update-model-context (exists)
imageIds none (files are out of scope here)

Two more pieces of context:

  • OpenAI's guide to MCP Apps in ChatGPT lists "widget-state persistence" among the capabilities that the shared specification does not cover. This PR is meant to close that.
  • OpenAI's MCP extensions specification has no request for UI-only state. It does hand a View's own model context back in hostContext["openai/modelContext"] on initialization or remount, which restores the model-visible half and is complementary to this PR.

In this repository, #194 first proposed setWidgetState and was closed in favour of ui/update-model-context (#125), which covers what the model sees. This PR covers the other half: what only the View sees.

Proposal

All of it is optional for Hosts.

  • ui/set-view-state { state } → {}. Replaces the saved state as a whole; null clears it. Refusals: -32602 (never as given, for example larger than maxBytes), -32603 (not now). No key parameter: the Host derives the key from the user, the server connection and the View instance.
  • hostContext.viewState. A Host that advertises the capability includes the saved state, or null, in every ui/initialize result, and never sends it in ui/notifications/host-context-changed: saves are not echoed.
  • hostCapabilities.viewState: { maxBytes }. A Host that has not read the saved state when it answers ui/initialize does not advertise it for that render, so a View cannot overwrite state it has not seen.
  • The Host answers a save with success only once the state would survive both a reload of the View and a reload of the Host.
  • The Host gives the saved state to neither the model nor the server.

Scope: one View instance, the View that the Host renders for one tool call (whoever initiated the call), however often it renders it. A wider scope (per conversation, per device, per account, as raised in #417) would be a separate capability with its own rules.

Schema change

interface HostContext {
  // ...
  viewState?: Record<string, unknown> | null; // null: nothing is saved
}

interface HostCapabilities {
  // ...
  viewState?: { maxBytes: number }; // bytes of the state as compact UTF-8 JSON
}

interface McpUiSetViewStateRequest {
  method: "ui/set-view-state";
  params: { state: Record<string, unknown> | null };
}
// Result: {} (Views MUST ignore fields that they do not know)
// Errors: -32602, this state cannot or will not be stored; -32603, it cannot be stored now

Example

// View → Host
{ "jsonrpc": "2.0", "id": 4, "method": "ui/set-view-state",
  "params": { "state": { "version": 1, "tab": "details" } } }

// Host → View, once the state is stored
{ "jsonrpc": "2.0", "id": 4, "result": {} }

// At the next render, Host → View: the ui/initialize result (other fields left out)
{ "jsonrpc": "2.0", "id": 1, "result": {
    "hostCapabilities": { "viewState": { "maxBytes": 65536 } },
    "hostContext": { "viewState": { "version": 1, "tab": "details" } } } }

What is in this PR

  • Spec (draft): the request, the capability, the host-context field, a "View State Persistence" section, and a "View State" item under Security Implications.
  • Types and schemas: spec.types.ts and the regenerated schemas.
  • SDK, View side: App.setViewState(state). It sends nothing unless the Host advertised the capability. On success it sets getHostContext().viewState to what it sent, without calling hostcontextchanged listeners.
  • SDK, Host side: AppBridge.onsetviewstate. The bridge calls the handler for one save at a time in the order received, coalesces the saves that arrive while one is in progress (only the newest is stored, and they all get its outcome), keeps the last saved state for a later ui/initialize on the same bridge, and never sends viewState in host-context-changed.
  • React: useViewState(app, initial), like useState, restored from the Host and saved back with a debounce.
  • Reference host: examples/basic-host now keeps its tool calls and each call's view state in its own localStorage, so both survive a reload of the page.
  • Examples: map-server (camera position) and pdf-server (current page) save through setViewState and fall back to viewUUID + localStorage on Hosts without the capability. basic-server-react keeps its unsent drafts with useViewState.
  • Docs: the Patterns guide leads with setViewState and contrasts it with the viewUUID + localStorage fallback. The OpenAI migration guide and the migrate-oai-app skill carry the mapping above.

Compatibility

Additive. A Host without the feature sends neither hostCapabilities.viewState nor hostContext.viewState. A View without it ignores both: the SDK keeps unknown host-context keys and drops unknown capability keys. Code that validates the ui/initialize result strictly against an older schema.json rejects a result that carries hostCapabilities.viewState, like any new capability.

Security considerations

  • The Host derives the key, so a View cannot read state saved by another View instance, server or user.
  • The Host treats the state as opaque, apart from automated policy or content scanning, and gives it to neither the model nor the server.
  • A View instance created for a new tool call starts with null, so saved state gives a View no identifier that carries from one conversation to another.
  • Limits, deletion and what Views should not save are in the new "View State" item under Security Implications.

Alternatives considered

  • The app's own server, through app-only tools: a round trip per save, its own authentication and storage, and data that the Host cannot delete.
  • ui/update-model-context: meant for the model, and never handed back to the View.
  • A ui/get-view-state request: a second round trip before first paint, for what every View needs at start.
  • One request for both halves, as setWidgetState({ modelContent, privateContent }) does: ui/update-model-context already exists with its own content-block shape and modality negotiation, and keeping the private half separate lets the spec say plainly that it never reaches the model.

Open questions

  1. The Host reads its store before it answers ui/initialize. Is that acceptable to Hosts with remote stores, or is a later delivery needed? As drafted, a View cannot tell a Host whose store is slow or down from one with no store (raised in View state persistence #417).
  2. What is the View instance of a View that is not tied to a tool call (Explore UI views that are not tied to specific tool calls #672), or of a copy in a forked, edited or shared conversation? (The security item assumes that each copy starts with null.)
  3. Two open copies do not see each other's saves; the last successful save wins. Should Hosts push saves to the other copy?
  4. state is an object or null. Is any JSON value needed?
  5. Is 64 KiB the right floor for maxBytes?
  6. Is the -32602/-32603 split wanted, where other requests show -32000?
  7. Stored state that the Host can read but not parse: withhold the capability for that render (as basic-host does), or evict it and hand back null?

Testing

  • Unit tests for App.setViewState and AppBridge.onsetviewstate (ordering, refusals and their error codes, the capability check, a second ui/initialize).
  • e2e in Chromium against basic-host: tests/e2e/view-state.spec.ts (state restored after a reload of the host page with no second tools/call, a new call starts with null, an oversize save is refused and the last state kept, closing a call deletes its state) and tests/e2e/pdf-page-restore.spec.ts (the pdf viewer on both the view-state path and the localStorage fallback).
  • Not covered yet: useViewState's save path has no unit tests (the repo has no DOM test environment; e2e covers the debounced save and restore), and map-server's port was only exercised against a stubbed Cesium.

Related

#213, #62, #427

AI use

Drafted with AI assistance (Claude). Opened as a Draft: I have not finished reviewing every line yet, and will before marking it ready.


Generated by Claude Code

claude and others added 7 commits November 4, 2026 01:02
Co-authored-by: ochafik <ochafik@anthropic.com>
Co-authored-by: ochafik <ochafik@anthropic.com>
Co-authored-by: ochafik <ochafik@anthropic.com>
Co-authored-by: ochafik <ochafik@anthropic.com>
Co-authored-by: ochafik <ochafik@anthropic.com>
…eact servers

Co-authored-by: ochafik <ochafik@anthropic.com>
…dget state

Co-authored-by: ochafik <ochafik@anthropic.com>
Comment thread docs/patterns.tsx Resolved
Comment thread docs/patterns.tsx Resolved
Comment thread docs/patterns.tsx Resolved
Comment thread src/app-bridge.examples.ts Resolved
Comment thread src/app.examples.ts Resolved
Comment thread src/react/useViewState.examples.tsx Resolved
Comment thread src/react/useViewState.examples.tsx Resolved
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

📖 Docs Preview Deployed

Preview (stable) https://pr-844.mcp-ext-apps-docs-preview.pages.dev
This commit https://39fd2163.mcp-ext-apps-docs-preview.pages.dev
Commit d4af896

Includes drafts and future-dated posts. All pages served with noindex, nofollow — search engines will not crawl this preview.

@pkg-pr-new

pkg-pr-new Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/ext-apps

npm i https://pkg.pr.new/@modelcontextprotocol/ext-apps@844

@modelcontextprotocol/server-basic-preact

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-preact@844

@modelcontextprotocol/server-basic-react

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-react@844

@modelcontextprotocol/server-basic-solid

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-solid@844

@modelcontextprotocol/server-basic-svelte

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-svelte@844

@modelcontextprotocol/server-basic-vanillajs

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-vanillajs@844

@modelcontextprotocol/server-basic-vue

npm i https://pkg.pr.new/@modelcontextprotocol/server-basic-vue@844

@modelcontextprotocol/server-budget-allocator

npm i https://pkg.pr.new/@modelcontextprotocol/server-budget-allocator@844

@modelcontextprotocol/server-cohort-heatmap

npm i https://pkg.pr.new/@modelcontextprotocol/server-cohort-heatmap@844

@modelcontextprotocol/server-customer-segmentation

npm i https://pkg.pr.new/@modelcontextprotocol/server-customer-segmentation@844

@modelcontextprotocol/server-debug

npm i https://pkg.pr.new/@modelcontextprotocol/server-debug@844

@modelcontextprotocol/server-lazy-auth

npm i https://pkg.pr.new/@modelcontextprotocol/server-lazy-auth@844

@modelcontextprotocol/server-map

npm i https://pkg.pr.new/@modelcontextprotocol/server-map@844

@modelcontextprotocol/server-pdf

npm i https://pkg.pr.new/@modelcontextprotocol/server-pdf@844

@modelcontextprotocol/server-scenario-modeler

npm i https://pkg.pr.new/@modelcontextprotocol/server-scenario-modeler@844

@modelcontextprotocol/server-shadertoy

npm i https://pkg.pr.new/@modelcontextprotocol/server-shadertoy@844

@modelcontextprotocol/server-sheet-music

npm i https://pkg.pr.new/@modelcontextprotocol/server-sheet-music@844

@modelcontextprotocol/server-system-monitor

npm i https://pkg.pr.new/@modelcontextprotocol/server-system-monitor@844

@modelcontextprotocol/server-threejs

npm i https://pkg.pr.new/@modelcontextprotocol/server-threejs@844

@modelcontextprotocol/server-transcript

npm i https://pkg.pr.new/@modelcontextprotocol/server-transcript@844

@modelcontextprotocol/server-video-resource

npm i https://pkg.pr.new/@modelcontextprotocol/server-video-resource@844

@modelcontextprotocol/server-wiki-explorer

npm i https://pkg.pr.new/@modelcontextprotocol/server-wiki-explorer@844

commit: 8825544

…aved view state

Co-authored-by: ochafik <ochafik@anthropic.com>

ochafik commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

cc @fbauer33 @idosal


Generated by Claude Code

claude and others added 2 commits November 4, 2026 03:06
Co-authored-by: ochafik <ochafik@anthropic.com>
Co-authored-by: ochafik <ochafik@anthropic.com>

ochafik commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

On the "unused function" and "unused variable" findings from the code-quality bot: I am leaving these as they are. They are all in the type-checked example files (*.examples.ts(x), docs/patterns.tsx), where each function exists only so that its //#region can be copied into JSDoc and the docs. None of the existing example functions in those files is called or exported either, so changing only the new ones would make them the odd ones out.


Generated by Claude Code

This branch has not been deployed

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

2 participants