All posts

MCP App State Persistence: useAppState, widgetState, localStorage, and Databases

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App FrameworkChatGPT App TestingClaude ConnectorsClaude Connector FrameworkState ManagementwidgetState
MCP App state belongs in different places depending on who needs to read it and how long it needs to last.

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 come from treating the app like a normal web app.

In a browser app, you can often reach for localStorage, cookies, URL params, or a client-side cache and fix the problem later. In an MCP App, the resource runs inside a sandboxed host iframe. The model, host, iframe, server tool, and user all see different slices of state.

That makes one question more useful than “How do I persist state?”

Who needs to read this state, and how long should it last?

TL;DR: Use React useState for UI-only state. Use useAppState or the standard host bridge for state the model should read in the current app instance. Use ChatGPT widgetState only for ChatGPT-scoped widget UI state. Use your server and database for anything that must survive across conversations, devices, accounts, or tool invocations. Treat iframe localStorage as disposable, and never put tokens or durable business records there.

Why State Persistence Is the Search Gap

Developers searching for MCP Apps, ChatGPT Apps, and Claude Connectors can already find setup guides, display mode references, OAuth guides, CSP examples, and structuredContent explainers. The state question tends to arrive after the first real UI works.

The search queries are scattered:

  • “MCP App localStorage not working”
  • “ChatGPT App widgetState vs structuredContent”
  • “useAppState persistence”
  • “Claude Connector state between tool calls”
  • “MCP App save form state”
  • “window.openai.setWidgetState when to use”
  • “MCP App database state”

Those are the same intent. The developer is trying to decide which state lane to use.

The State Decision Table

Start with the reader and the lifetime.

State typeReaderLifetimeBest place
Hover, menu open, selected tabUI onlyCurrent renderReact useState
Selected row the model should discussModel and UICurrent app instanceuseAppState or host bridge context
Draft form fields before submitUI first, maybe model laterCurrent workflowuseState, then useAppState on commit
ChatGPT widget scroll position or panel stateChatGPT widgetSame widget instancewidgetState where available
Cart, approval, saved draft, user preferenceServer, tools, future sessionsDurableDatabase through server tools
Auth token or API credentialServer onlyToken lifetimeMCP auth flow or server session
Tool render dataUI and often modelTool result lifetimestructuredContent and _meta

If a row feels ambiguous, choose the more durable and more private place. It is easier to copy a small summary into model-visible state than to remove leaked private data after it has entered the conversation.

useState Is for UI Mechanics

Use React state for state that only affects the current render:

  • whether a menu is open
  • which tab is selected
  • the current hover or focus affordance
  • a local filter that the model does not need to know about
  • a text field while the user is still typing

This state can disappear without breaking the workflow. If the iframe reloads and the menu closes, nobody cares. If the app moves from inline to fullscreen and the selected tab resets, that may be acceptable too.

import { useState } from 'react';

export function ResultsToolbar() {
  const [isFilterOpen, setIsFilterOpen] = useState(false);

  return (
    <div>
      <button type="button" onClick={() => setIsFilterOpen((open) => !open)}>
        Filters
      </button>
      {isFilterOpen ? <FilterPanel /> : null}
    </div>
  );
}

Do not use plain useState when the next assistant turn depends on the value. If the user selects three invoices and then asks “summarize those,” the model needs to know which invoices were selected.

useAppState Is for Model-Visible UI State

useAppState is the portable MCP App path for state the model should see. In sunpeak, it works like React state, but it syncs through the host so ChatGPT, Claude, and other MCP App hosts can include meaningful UI state in the model context.

Use it for:

  • selected rows
  • wizard progress
  • approval decisions
  • submitted form choices
  • filters the model should reference
  • a user preference that matters for the current answer
import { SafeArea, useAppState, useToolData } from 'sunpeak';

type Invoice = {
  id: string;
  customer: string;
  totalCents: number;
};

type InvoiceOutput = {
  invoices: Invoice[];
};

type InvoiceState = {
  selectedIds: string[];
};

export function InvoicePicker() {
  const { output } = useToolData<unknown, InvoiceOutput>();
  const [state, setState] = useAppState<InvoiceState>({ selectedIds: [] });

  if (!output) return <SafeArea>No invoices found.</SafeArea>;

  function toggleInvoice(id: string) {
    setState((previous) => {
      const selected = new Set(previous.selectedIds);
      if (selected.has(id)) selected.delete(id);
      else selected.add(id);

      return { selectedIds: Array.from(selected) };
    });
  }

  return (
    <SafeArea className="p-4">
      <ul>
        {output.invoices.map((invoice) => (
          <li key={invoice.id}>
            <label>
              <input
                type="checkbox"
                checked={state.selectedIds.includes(invoice.id)}
                onChange={() => toggleInvoice(invoice.id)}
              />
              {invoice.customer}: ${(invoice.totalCents / 100).toFixed(2)}
            </label>
          </li>
        ))}
      </ul>
    </SafeArea>
  );
}

After the user checks invoices, the model can answer follow-up prompts about the selected set. That is the point of app state in an AI host: user actions become context, not hidden component internals.

Keep the state compact. Store IDs, mode names, form choices, and short values. Do not copy entire result payloads into app state when the tool result already has them.

widgetState Is ChatGPT-Scoped UI State

OpenAI’s Apps SDK state management guide separates business data, UI state, and cross-session state. ChatGPT also exposes widget state through window.openai APIs such as widgetState and setWidgetState.

That state is useful when you build a ChatGPT App and want to restore UI state for the same widget instance. Think of it as host-managed widget memory, not application storage.

Good uses:

  • restoring a collapsed panel
  • keeping a selected tab after rerender
  • preserving scroll or pagination in the current message
  • remembering an in-widget draft until the workflow completes

Bad uses:

  • auth tokens
  • long-lived user preferences
  • paid order state
  • data that another user, device, or conversation needs
  • anything your server must audit

If you use sunpeak’s useAppState, most apps do not need to call window.openai.setWidgetState directly. The hook gives you a portable model-visible state path. Drop to ChatGPT-specific APIs only when you need a ChatGPT-only behavior and you have feature detection.

function saveChatGPTWidgetState(state: unknown) {
  if (typeof window === 'undefined') return;

  window.openai?.setWidgetState?.(state);
}

Guard this path. Claude Connectors and other MCP App hosts do not expose ChatGPT’s window.openai object.

localStorage Is the Wrong Default

Iframe localStorage is tempting because it is familiar. It is also the source of many MCP App bugs.

An MCP App resource cannot read the parent host’s storage. It only gets whatever browser storage is available to the iframe origin the host uses for your resource. That storage can change when:

  • the host changes the sandbox origin
  • the resource loads from a new domain
  • the user clears browser data
  • the host rerenders the message in a fresh context
  • the app runs in a desktop shell instead of a normal browser tab
  • privacy settings block or partition third-party storage

Use localStorage only for state that can vanish safely:

function readPreferredDensity() {
  try {
    return localStorage.getItem('invoice-density') === 'compact' ? 'compact' : 'comfortable';
  } catch {
    return 'comfortable';
  }
}

This is fine because density is cosmetic. If it resets, the app still works.

This is not fine:

localStorage.setItem('accessToken', token);
localStorage.setItem('approvedPurchaseId', purchaseId);
localStorage.setItem('draftContract', JSON.stringify(contract));

Tokens belong in the server-side auth flow. Purchases and approvals belong in your database. Long drafts belong on the server if losing them would hurt the user.

Durable State Belongs Behind Tools

If state must survive across conversations, devices, or app instances, save it on the server. The resource should call a server tool, and the tool should validate auth, validate input, write the database record, and return a short result.

// src/tools/save-invoice-selection.ts
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';

export const tool: AppToolConfig = {
  title: 'Save Invoice Selection',
  description: 'Save the user selected invoices for later review.',
  annotations: {
    readOnlyHint: false,
    destructiveHint: false,
    idempotentHint: false,
  },
  _meta: {
    ui: { visibility: ['app'] },
  },
};

export const schema = {
  selectedIds: z.array(z.string()).max(100),
};

type Args = z.infer<z.ZodObject<typeof schema>>;

export default async function saveInvoiceSelection(args: Args, extra: ToolHandlerExtra) {
  const userId = extra.authInfo?.clientId;

  if (!userId) {
    return {
      isError: true,
      content: [{ type: 'text', text: 'Sign in before saving invoice selections.' }],
    };
  }

  const saved = await saveSelection({
    userId,
    selectedIds: args.selectedIds,
  });

  return {
    content: [
      {
        type: 'text',
        text: `Saved ${args.selectedIds.length} selected invoices.`,
      },
    ],
    structuredContent: {
      selectionId: saved.id,
      selectedCount: args.selectedIds.length,
    },
  };
}

The resource can call that tool from a button:

import { useCallServerTool } from 'sunpeak';

function SaveSelectionButton({ selectedIds }: { selectedIds: string[] }) {
  const callServerTool = useCallServerTool();

  async function saveSelection() {
    await callServerTool('save_invoice_selection', { selectedIds });
  }

  return (
    <button type="button" disabled={selectedIds.length === 0} onClick={saveSelection}>
      Save selection
    </button>
  );
}

This gives you a real audit trail and a stable record. It also keeps the model context small because the model only needs a summary and an ID, not a copy of every database field.

Do Not Confuse Tool Results with State

Tool results and app state solve different problems.

structuredContent is the data returned by a tool call. It is the source material the app renders. _meta carries UI-only helper data when the host supports it. content is the text result a model or non-app client can read.

App state is what changes after the resource renders, usually because the user interacts with the UI.

tool result -> initial render data
app state -> user changed something after render
database -> durable record outside the iframe

A common bug is copying every tool result field into app state on load. That creates two sources of truth. Prefer this pattern:

  1. Render from structuredContent.
  2. Store only user choices in app state.
  3. Save durable choices through a tool.
  4. Return a compact content summary and typed structuredContent from the save tool.

Multi-Step Forms Need Two Lifetimes

Multi-step forms are where state design gets messy.

While the user types, local component state is enough. The model does not need to see every keystroke. When the user completes a step, sync a compact step summary to app state. When the user submits the form, write the final payload to the server.

type DraftState = {
  currentStep: 'details' | 'review' | 'submitted';
  completedSteps: string[];
};

function ContractWizard() {
  const [localDraft, setLocalDraft] = useState({ title: '', ownerEmail: '' });
  const [appState, setAppState] = useAppState<DraftState>({
    currentStep: 'details',
    completedSteps: [],
  });
  const callServerTool = useCallServerTool();

  function continueToReview() {
    setAppState({
      currentStep: 'review',
      completedSteps: ['details'],
    });
  }

  async function submit() {
    await callServerTool('save_contract_draft', localDraft);
    setAppState({
      ...appState,
      currentStep: 'submitted',
    });
  }

  return (
    <ContractForm
      draft={localDraft}
      step={appState.currentStep}
      onDraftChange={setLocalDraft}
      onContinue={continueToReview}
      onSubmit={submit}
    />
  );
}

The model can see the workflow stage. Your server stores the submitted draft. The iframe is not trusted with the only copy.

Authentication State Is Not App State

Auth tokens should not go in useAppState, widgetState, localStorage, structuredContent, or _meta.

For authenticated MCP Apps and Claude Connectors, use the MCP authorization flow and read the authenticated identity in the server tool. If the resource needs to fetch directly from an API, prefer a short-lived, narrowly scoped reference returned by a server tool, and declare the target origin in resource CSP. Even then, avoid placing bearer tokens in model-visible fields.

If you need a rule of thumb: the iframe may render authenticated data, but the server should hold authenticated power.

That keeps the app safer when hosts rerender, users share conversations, logs capture model context, or a later refactor moves data between content, structuredContent, and app state.

Test the State Contract

State persistence bugs are easy to miss because the happy path works in one host and fails after a reload, rerender, or follow-up prompt.

Add tests for each state layer.

Unit tests

Unit test component-only state and hook calls. Mock useAppState and assert that meaningful interactions call the setter with the right compact shape.

expect(setAppState).toHaveBeenCalledWith({
  selectedIds: ['inv_123'],
});

Integration tests

Call the MCP tool that writes durable state. Assert auth checks, schema validation, database writes, and the returned content and structuredContent.

const result = await mcp.callTool('save_invoice_selection', {
  selectedIds: ['inv_123', 'inv_456'],
});

expect(result.isError).toBeFalsy();
expect(result.structuredContent).toMatchObject({
  selectedCount: 2,
});

E2E tests

Render the resource in the local inspector, click the UI, and assert the visible state changes. Then reload or rerender the tool state you care about.

test('selection survives the app interaction path', async ({ inspector }) => {
  const result = await inspector.renderTool('list_invoices');
  const app = result.app();

  await app.getByLabel('Acme: $120.00').check();
  await expect(app.getByText('1 selected')).toBeVisible();
});

Cross-host tests

If you support ChatGPT Apps and Claude Connectors, test the portable useAppState path in both host runtimes. Add a separate ChatGPT-only test only for direct widgetState behavior.

The failure cases matter:

  • app state missing on initial render
  • state from an older schema version
  • user refreshes the host message
  • tool call is cancelled
  • save tool returns an error
  • host does not support a ChatGPT-specific API
  • model-visible state accidentally includes private fields

sunpeak helps here because the inspector can render replicated ChatGPT and Claude runtimes locally, and the test framework can pin tool results and app states without spending host credits on every CI run.

A Practical Build Order

Use this order when adding state to an MCP App:

  1. Define the durable server record first.
  2. Define the tool result that renders the initial UI.
  3. Define the compact app state the model should see.
  4. Keep typing state local until the user commits a step.
  5. Save durable state through an app-only server tool.
  6. Add host-specific widgetState only when ChatGPT needs UI restoration beyond the portable path.
  7. Test reload, missing state, and cross-host behavior.

That order keeps the app understandable because every state field has a clear owner.

Use sunpeak to Catch State Bugs Locally

State bugs are expensive when the only feedback loop is a real ChatGPT or Claude session. Build the contract locally first.

With sunpeak, you can:

  • render the same resource in replicated ChatGPT and Claude runtimes
  • inspect useAppState values while clicking the UI
  • write E2E tests for app state transitions
  • validate tool result shapes before the host sees them
  • test durable writes through the MCP tool layer
  • run the same checks in CI without paid host accounts or AI credits

Start with npx sunpeak new for a new app, or use npx sunpeak inspect --server URL against an existing MCP server.

The important part is not the hook name. It is the habit: keep UI state, model-visible state, host widget state, and durable server state separate. Once those lanes are clear, ChatGPT Apps and Claude Connectors become much easier to debug.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

How do I persist state in an MCP App?

Use local React state for UI-only state, useAppState or the host bridge for state the model should see during the conversation, ChatGPT widgetState for ChatGPT-scoped widget UI state, and your own database for state that must survive across conversations, devices, or users. Do not assume iframe localStorage is durable enough for business data.

Should MCP Apps use localStorage?

Only for disposable UI preferences when you have a fallback. MCP App resources run in sandboxed iframes, so they cannot read the parent host storage and hosts may clear iframe storage between renders, sessions, or origins. Use localStorage for non-critical UI hints at most, not auth tokens, carts, approvals, drafts, or records the server needs.

What is the difference between useState and useAppState in MCP Apps?

React useState stays inside the component and disappears when the iframe reloads. useAppState syncs state to the host so the model can read meaningful user interactions such as selected rows, form choices, confirmations, and workflow progress. Use useState for hover, tabs, and open panels. Use useAppState when the next model turn needs the state.

What is ChatGPT widgetState?

ChatGPT widgetState is host-managed state for a ChatGPT App widget instance. It can restore UI state when a user reopens or rerenders the same widget. It is useful for message-scoped UI state, but it is not a database and should not be the only copy of durable business data.

Can Claude Connectors use ChatGPT widgetState?

No. widgetState is a ChatGPT-specific compatibility API. Portable MCP Apps and interactive Claude Connectors should use standard MCP App state APIs through a framework hook such as sunpeak useAppState, and store durable records on the server through MCP tools.

Where should I store multi-step form state in an MCP App?

Keep temporary field edits in component state while the user is typing. Sync committed steps with useAppState when the model should know the progress. Save the completed form to your backend through a server tool when the user submits or when the workflow needs to resume later.

Where should auth tokens live in an MCP App?

Auth tokens should live on the server side or in the host-managed MCP authorization flow. Do not store tokens in iframe localStorage, widgetState, structuredContent, or model-visible app state. Pass short-lived, scoped references to the resource only when the UI needs them, and prefer app-only tools for privileged operations.

How do I test MCP App state persistence?

Test state at the layer where it lives. Unit test component state transitions, integration test tool results and server writes, run inspector E2E tests for useAppState or host bridge updates, and add cross-host tests for ChatGPT and Claude when you support both. Include reload, rerender, cancelled tool, and missing-state cases.