MCP App State Persistence: useAppState, widgetState, localStorage, and Databases (September 2026)

MCP App state belongs in different places depending on who needs to read it and how long it needs to last.
Most MCP App state bugs start with one overloaded word: persistence.
A selected tab, the row a user wants the model to explain, a saved invoice, an OAuth token, and a long-running task are all state. They do not have the same owner, reader, lifetime, or security boundary.
That distinction matters more after the MCP 2026-07-28 release. The core protocol is now stateless, but MCP Apps can still run stateful products. Your application has to make that state explicit.
TL;DR: Keep presentation state in the UI. Use useAppState or ui/update-model-context for compact state the model should read, not as a database. Use ChatGPT widgetState only for one rendered ChatGPT UI instance. Use localStorage for recoverable view hints keyed by a stable server ID. Put authoritative records, cross-session preferences, auth, and resumable workflows on the server. Return explicit IDs and revisions so later self-contained MCP requests can restore the right state.
Start With Owner, Reader, and Lifetime
Ask three questions before choosing an API:
- Who owns the source of truth?
- Who needs to read the value?
- What event is allowed to erase it?
The answers sort most state into clear lanes:
- React state: Hover, open panels, and local sorting belong to the current UI instance and may disappear on remount.
- Model context: Selected IDs the model should discuss belong in
useAppStateorui/update-model-contextfor the current conversation context. - ChatGPT widget state: A panel or pagination snapshot can use
widgetStatebetween renders of one ChatGPT UI instance. - Recoverable browser view state: A PDF page or map camera can use
localStoragewith a stable object ID, schema version, and fallback. - Durable business state: Tickets, carts, approvals, and saved drafts belong in a database behind authorized server tools.
- Resumable workflow state: A server record plus an explicit opaque handle can live until the workflow finishes or expires.
- Authentication state: Access tokens belong in the host authorization layer and server for the credential lifetime.
Do not choose the lane by convenience. Choose it by the worst consequence of losing, exposing, or overwriting the value.
MCP Core Is Stateless, Your Product Can Be Stateful
The MCP 2026-07-28 release removed the initialize handshake, Mcp-Session-Id, and protocol-level session. Each request now carries its protocol version, client information, and capabilities.
That is an infrastructure win because any server instance can handle a request. It also removes a tempting hiding place for application state.
If a tool starts a workflow that must continue later, save the workflow in your own store and return an explicit handle:
{
"content": [
{
"type": "text",
"text": "Draft saved. Ask me to continue it with draft_7f2."
}
],
"structuredContent": {
"draftId": "draft_7f2",
"revision": 4,
"status": "awaiting_review"
}
}
A later tool call sends draftId and, for a write, the expected revision. The server loads the record, checks authorization, applies the change, increments the revision, and returns the new snapshot.
The model can see and pass the handle. The server can expire, audit, or revoke it. A load balancer does not need to route the next request to the same process.
Use a Tasks extension task ID when the work is an asynchronous tool execution that fits that lifecycle. Use your own domain ID for a saved business object or draft. Do not call every stored object a task.
Tool Results Are Initial Data, Not Persistence
An MCP tool result supplies the initial render:
contentgives the model and non-UI clients a useful text result.structuredContentgives the model and UI typed result data.- result
_metacan carry UI-only helper data.
Those fields describe one tool call. They are not a durable state store.
A reliable render cycle looks like this:
- The server returns the authoritative snapshot with an object ID and revision.
- The UI renders that snapshot.
- The user changes temporary presentation state locally.
- The UI sends only useful model context through the app bridge.
- A server tool handles any durable mutation.
- The tool returns a new authoritative snapshot and revision.
This avoids two sources of truth. Do not copy the full tool result into useAppState, widgetState, or localStorage on mount and then mutate the copy forever.
React State Is for One Rendered UI Instance
Use React useState for state that can disappear when the resource remounts:
- open panels and menus
- hover and focus details
- local sorting
- an uncommitted input value
- animation and drag state
import { useState } from 'react';
export function ResultsToolbar() {
const [sort, setSort] = useState<'newest' | 'oldest'>('newest');
const [filtersOpen, setFiltersOpen] = useState(false);
return (
<div>
<button type="button" onClick={() => setFiltersOpen((open) => !open)}>
Filters
</button>
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)}>
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
</select>
{filtersOpen ? <FilterPanel /> : null}
</div>
);
}
If a remount resets the value and the user loses meaningful work, this lane is too short-lived.
useAppState Is Model Context, Not a Database
MCP Apps provides ui/update-model-context for state the model should use on a later turn. In sunpeak, useAppState wraps that portable bridge method in a React-style hook.
Use it for selected record IDs, a compact approval choice, the current wizard step, filters that change the meaning of a follow-up question, or a short summary of staged edits.
import { SafeArea, useAppState, useToolData } from 'sunpeak';
type Invoice = {
id: string;
customer: string;
totalCents: number;
};
type InvoiceOutput = {
invoices: Invoice[];
};
type InvoiceViewState = {
selectedIds: string[];
};
export function InvoicePicker() {
const { output } = useToolData<unknown, InvoiceOutput>();
const [viewState, setViewState] = useAppState<InvoiceViewState>({
selectedIds: [],
});
if (!output) return <SafeArea>No invoices found.</SafeArea>;
function toggleInvoice(id: string) {
setViewState((previous) => {
const selected = new Set(previous.selectedIds);
if (selected.has(id)) selected.delete(id);
else selected.add(id);
return { selectedIds: [...selected] };
});
}
return (
<SafeArea className="p-4">
{output.invoices.map((invoice) => (
<label key={invoice.id}>
<input
type="checkbox"
checked={viewState.selectedIds.includes(invoice.id)}
onChange={() => toggleInvoice(invoice.id)}
/>
{invoice.customer}: {formatCurrency(invoice.totalCents)}
</label>
))}
</SafeArea>
);
}
The model needs IDs and meaning, not a copy of every invoice field. Keep model context small, omit secrets and private UI details, and assume it may become part of a later model turn.
useAppState does not promise cross-device or cross-conversation recovery. If a selection becomes a saved review set, call a server tool and return a durable ID.
ChatGPT widgetState Is an Optional Instance Snapshot
OpenAI’s current ChatGPT UI guide separates authoritative business data, ephemeral UI state, and cross-session state.
ChatGPT also offers optional widget-scoped persistence:
- Read from
window.openai.widgetState. - Write with
window.openai.setWidgetState(state). - Call the setter after each meaningful UI change.
- Do not
awaitit because the setter is synchronous.
type WidgetState = {
version: 2;
selectedId: string | null;
panel: 'list' | 'details';
};
const fallback: WidgetState = {
version: 2,
selectedId: null,
panel: 'list',
};
function readWidgetState(): WidgetState {
const saved = window.openai?.widgetState;
if (!saved || saved.version !== 2) return fallback;
return saved as WidgetState;
}
function writeWidgetState(next: WidgetState) {
window.openai?.setWidgetState?.(next);
}
Widget state belongs to one rendered UI instance. Use it to restore a selected item, open panel, pagination position, or in-widget draft that can still be rebuilt from server data.
Do not use widget state as the only copy of a ticket, order, approval, or account preference. Other hosts may not expose this ChatGPT extension, so feature-detect setWidgetState and provide a useful default.
ChatGPT also supports a structured widget-state shape with modelContent, privateContent, and imageIds. Use modelContent only for the part the model needs. Keep presentation details in privateContent. Include only authorized ChatGPT file IDs in imageIds.
localStorage Is for Recoverable View State
The official MCP Apps patterns recommend localStorage for recoverable view state, such as a PDF page or map camera, when the server provides a stable identifier.
That advice has a narrow boundary. localStorage belongs to the iframe’s browser and origin. It does not give you reliable cross-device, cross-conversation, or cross-host storage.
Use a stable object ID and schema version in the key:
type MapView = {
version: 1;
latitude: number;
longitude: number;
zoom: number;
};
function mapViewKey(mapId: string) {
return 'mcp-map-view:v1:' + mapId;
}
export function loadMapView(mapId: string): MapView | null {
try {
const raw = localStorage.getItem(mapViewKey(mapId));
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<MapView>;
if (parsed.version !== 1) return null;
if (typeof parsed.latitude !== 'number') return null;
if (typeof parsed.longitude !== 'number') return null;
if (typeof parsed.zoom !== 'number') return null;
return parsed as MapView;
} catch {
return null;
}
}
Good localStorage candidates include the last page in a document, a map camera position, display density, a dismissed tutorial hint, or a non-sensitive draft filter.
Bad candidates include tokens, purchase state, approvals, saved documents, and any record the server must audit.
Always handle missing, malformed, blocked, and old-version data. The UI should remain useful when storage returns nothing.
Durable State Belongs on the Server
Save state on the server when it must survive a new conversation, browser, device, host, or UI instance.
The write path should include:
- authenticated user identity derived from a validated credential
- object-level authorization
- schema validation
- an idempotency key for retried mutations
- an expected revision for concurrent writes
- an audit record for sensitive actions
- an expiry policy for temporary drafts
A save request can use this shape:
{
"draftId": "draft_7f2",
"expectedRevision": 4,
"idempotencyKey": "save_01K5M4V0G8",
"changes": {
"title": "September invoice review"
}
}
If the stored revision is already 5, reject the stale write and return the current snapshot. Do not let an older UI instance silently overwrite a newer change.
For a successful mutation, return enough data for both the model and UI:
{
"content": [
{
"type": "text",
"text": "Saved draft draft_7f2 at revision 5."
}
],
"structuredContent": {
"draftId": "draft_7f2",
"revision": 5,
"status": "draft",
"updatedAt": "2026-09-18T10:30:00Z"
}
}
Keep private operational details out of model-visible fields. Result _meta is UI-only, but it is still delivered to code in the iframe, so it is not a safe place for long-lived secrets.
Multi-Step Forms Need Checkpoints
Form state usually spans three lifetimes:
- Keystrokes live in component state.
- A compact step summary goes to model context if the model needs it.
- A server draft saves work that would be painful to lose.
Do not save every keystroke to the model or database. Choose checkpoints such as completing a step, attaching a file, leaving a section, or waiting several idle seconds.
Each server draft should have a stable opaque ID, owning user or workspace, schema version, revision, timestamps, expiry policy, and status such as draft, submitted, or cancelled.
On restore, load the current server snapshot first. Then apply compatible presentation state. If a saved UI selection points at a record the server no longer returns, discard the selection instead of reviving stale data.
Authentication State Is a Separate Boundary
Never put access tokens, refresh tokens, session cookies, or API keys in useAppState, ui/update-model-context, widgetState, localStorage, content, structuredContent, or result _meta.
Use the MCP authorization flow. The server should derive the user from a validated credential, then perform an object-level check on every tool call.
Do not treat an MCP client ID as a user ID. Client identity tells you which client made a request, not which person owns a draft or invoice.
The iframe can display authorized data, but the server should hold the authority to read and change it.
Version Every Stored Shape
Old conversations can reopen old UI resources. A browser can retain localStorage after you deploy a new bundle. ChatGPT can restore widget state written by an earlier release. A server draft may outlive several schema versions.
Put a version in every stored client shape:
type PersistedSelection =
| { version: 1; selectedId: string | null }
| { version: 2; selectedIds: string[]; focusedId: string | null };
Then migrate a known old version, accept compatible fields and fill defaults, or discard the value and rebuild from server data. Reject unknown versions. A quiet reset is safer than applying a stale approval or mutation intent.
Version server records separately from UI snapshots. Database migrations, public tool schemas, and presentation state do not need to share one version number.
Test Lifetimes, Not Just Clicks
State tests should recreate the event that might erase or conflict with the value.
| Boundary | Test |
|---|---|
| Component | Rerender and remount the resource |
| Model context | Inspect the exact ui/update-model-context payload |
| ChatGPT widget | Restore the same UI instance with saved widgetState |
| Browser view | Reload with missing, malformed, and older localStorage |
| Server draft | Resume in a new browser or conversation |
| Concurrency | Save from two UI instances with the same revision |
| Retry | Send the same idempotency key twice |
| Authorization | Read and write another user’s object |
| Cross-host | Run with optional host persistence unavailable |
Unit tests should cover state reducers, validation, and migrations. MCP integration tests should cover tool schemas, auth, durable writes, conflicts, and result shapes. Browser tests should cover remounts, reloads, capability fallbacks, and visible recovery.
With the sunpeak Inspector, you can render fixed tool results in ChatGPT and Claude-style runtimes, inspect model-visible state, and turn those states into Playwright tests. Keep a focused real-host check for ChatGPT widgetState because a local runtime cannot prove current production host behavior.
For an existing MCP server:
npx sunpeak inspect --server http://localhost:8000/mcp
npx sunpeak test init --server http://localhost:8000/mcp
Test the failure paths too: storage denied, state missing, schema version unknown, save cancelled, tool error, stale revision, expired handle, and optional host API unavailable.
A State Review Checklist
Before shipping a stateful MCP App feature, check each field:
- Name the source of truth.
- Name every reader.
- Define the required lifetime.
- Mark whether the model may see it.
- Mark whether the iframe may see it.
- Add a schema version if it survives a render.
- Add an explicit handle if a later MCP request needs it.
- Add authorization, revisions, and idempotency for durable writes.
- Test the event that should preserve it.
- Test the event that should erase it.
This review catches the common category mistakes before they become recovery bugs.
Where sunpeak Fits
sunpeak keeps the state lanes visible while you build and test:
useToolDatareads initial tool data.useAppStatewraps portable model-context updates.useCallServerToolsends durable mutations back through MCP.- Simulation files pin initial tool results.
- The Inspector shows ChatGPT and Claude-style host behavior.
- Playwright tests cover remount, reload, error, and fallback states.
Start with npx sunpeak new for a new app, or connect the Inspector to an existing MCP server with npx sunpeak inspect --server URL.
The hook name is the easy part. Reliable MCP App state comes from giving each value one owner, one lifetime, an explicit visibility boundary, and a tested recovery path.
Get Started
npx sunpeak newFurther Reading
- Interactive MCP Apps with useAppState - model-visible UI state
- MCP App model context and UI state
- MCP App actions - callServerTool, sendMessage, and updateModelContext
- Testing MCP App data flow
- MCP App authentication and OAuth
- Cross-host testing for MCP Apps
- MCP App framework
- Official MCP Apps patterns - persisting view state
- OpenAI guide to state in ChatGPT UI
- MCP 2026-07-28 specification release
- sunpeak docs
Frequently Asked Questions
How do I persist state in an MCP App?
First decide who owns the state and how long it must live. Keep temporary presentation state in the UI, send compact model-visible context through ui/update-model-context or a hook such as sunpeak useAppState, use ChatGPT widgetState only for one rendered ChatGPT UI instance, and save authoritative business data or cross-session preferences in storage controlled by your server. Return an explicit record ID and revision so later tool calls can restore the right server record.
Does useAppState persist MCP App state?
useAppState keeps React-style state and syncs the chosen value to model-visible context through the host. It is useful for selected IDs, staged choices, and workflow progress that the model should use on a later turn. Treat it as conversation context, not durable storage. If the value must survive a different conversation, device, account session, or host, save it through a server tool.
Should MCP Apps use localStorage?
Use localStorage only for recoverable view state such as a map camera, PDF page, density preference, or dismissed hint. Key it with a stable server-provided object ID and a schema version, handle storage failures, and keep a default fallback. Do not use it for tokens, approvals, carts, saved drafts, or the only copy of business data because sandbox origins, browser partitions, devices, and host environments can differ.
What is ChatGPT widgetState?
ChatGPT widgetState is optional state scoped to one rendered ChatGPT UI instance. Read the snapshot from window.openai.widgetState and write it synchronously with window.openai.setWidgetState after meaningful UI changes. It can restore presentation state between renders, but OpenAI says it is not the source of truth for business data or durable cross-session storage.
How does the stateless MCP 2026-07-28 protocol affect application state?
The MCP 2026-07-28 core removed the protocol session and initialization handshake. Each request is self-contained, so servers should not hide workflow state in a transport session. Save state in your own store and return an explicit opaque handle, record ID, or task ID that a later tool call can send back. The protocol is stateless while the application can remain stateful.
Where should multi-step form state live in an MCP App?
Keep unsaved keystrokes in component state. Put a compact step summary in model-visible context only when the model needs it. Save a server draft at meaningful checkpoints when losing the work would hurt the user. Give the draft a stable ID, revision, owner, expiry policy, and idempotent save operation so rerenders, retries, and concurrent UI instances do not overwrite newer work.
Where should auth tokens live in an MCP App?
Keep auth tokens in the host-managed MCP authorization flow or on the server. Never put tokens in useAppState, widgetState, localStorage, content, structuredContent, or result _meta. The server must derive the user from a validated credential and check that user against the requested object on every read or write.
How do I test MCP App state persistence?
Test each lifetime at its boundary. Unit test component and model-context updates, integration test authenticated server reads and writes, and run browser tests for remount, rerender, reload, missing storage, schema migration, and host capability fallbacks. Add concurrent-instance, stale-revision, retry, and cross-device tests for durable state, plus a focused real-host check for host-managed widget behavior.