MCP App Forms: Validation, Submit Actions, and Multi-Step Workflows (July 2026)

MCP App forms need a clear split between local UI state, model-visible state, server validation, and confirmed tool actions.
MCP App tutorials often start with read-only cards because cards are the shortest path to a visible result.
Forms are where the app starts to behave like a product. The user edits fields, fixes validation errors, confirms a write, and then asks the model a follow-up question about what just happened. That flow has more moving parts than a normal React form because the UI lives in a host iframe and the model also needs a reliable view of the state.
TL;DR: Build MCP App forms around four lanes: local draft state, model-visible app state, server-side tool validation, and explicit submit actions. Use local state for keystrokes, app state for decisions the model should see, app-only tools for UI submits, and honest tool annotations for writes. Test form states in a local inspector before relying on live ChatGPT or Claude sessions.
Why Forms Are the Search Gap
The current MCP App content map has many guides for tool results, resource metadata, display modes, app state, and testing. Developers still hit a practical gap when they search for:
MCP App form submitChatGPT App form validationClaude Connector approval formMCP App multi-step workflowwindow.openai.callTool form submitMCP App app-only submit toolupdate model context from form
Those are second-step searches. The resource already renders. Now the developer needs to know what happens when a user edits and submits something.
A useful form has to keep local UI responsive, keep the model in sync where useful, validate at the server boundary, and route writes through MCP tools so the host can reason about the action.
The Four Lanes of Form State
Most form bugs come from mixing these lanes.
| Lane | Owner | Use it for | Avoid |
|---|---|---|---|
| Local draft state | Resource component | Keystrokes, touched fields, local errors, open sections | Model-visible facts |
| App state | Host bridge | Selected IDs, current step, confirmed choices, short summaries | Every character typed |
| Tool input and output | MCP server | Initial data, defaults, validation result, saved record | UI-only helper maps |
| Tool annotations and auth | Host plus server | Confirmation, read/write risk, permissions | Hiding risky actions in vague tools |
OpenAI’s current Apps SDK component planning docs make the same split in different words: components should receive the data they need through the tool response, render initial state from structuredContent, render UI-initiated tool calls from their returned tool result, and use ui/update-model-context when the model needs to stay in sync with UI state.
That maps cleanly to portable MCP Apps. The tool result gives the resource a typed starting point. The iframe owns draft interactions. The host bridge carries only the state that should affect future model turns.
Start with a Read-Only Tool
For a form flow, the first tool should usually prepare the form rather than commit the action.
import { z } from 'zod';
import type { AppToolConfig } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
title: 'Review Invoice Changes',
description: 'Prepare an invoice update form for user review before saving changes.',
annotations: {
readOnlyHint: true,
destructiveHint: false,
openWorldHint: false,
},
_meta: {
ui: {
resourceUri: 'ui://invoice-review/form.html',
visibility: ['model', 'app'],
},
},
};
export const schema = {
invoiceId: z.string(),
};
export default async function reviewInvoiceChanges({ invoiceId }: { invoiceId: string }) {
const invoice = await loadInvoice(invoiceId);
return {
content: [
{
type: 'text' as const,
text: `Opened an editable review form for invoice ${invoice.number}. No changes saved yet.`,
},
],
structuredContent: {
invoiceId: invoice.id,
number: invoice.number,
customerName: invoice.customerName,
amountCents: invoice.amountCents,
dueDate: invoice.dueDate,
status: invoice.status,
},
};
}
The model can call this tool because it is read-only. The tool returns the facts the resource needs to render the form. Nothing changes in the upstream system yet.
That preview-first shape is easier to review, test, and submit. It also gives clients without MCP App UI a useful text fallback: the user knows a review form opened and no save happened.
Keep Draft Fields Local
In the resource, keep draft values in local state until they become meaningful to the model.
import { useState } from 'react';
import { SafeArea, useAppState, useToolData } from 'sunpeak';
interface InvoiceFormData {
invoiceId: string;
number: string;
customerName: string;
amountCents: number;
dueDate: string;
status: 'draft' | 'open' | 'paid';
}
interface ModelState {
invoiceId: string;
step: 'editing' | 'ready_to_submit' | 'submitted';
changedFields: string[];
}
export function InvoiceReviewForm() {
const { output } = useToolData<unknown, InvoiceFormData>();
const [, setModelState] = useAppState<ModelState | null>(null);
const [draft, setDraft] = useState(output);
if (!output || !draft) return null;
function updateDueDate(dueDate: string) {
setDraft((current) => (current ? { ...current, dueDate } : current));
}
function markReady() {
const changedFields = diffFields(output, draft);
setModelState({
invoiceId: output.invoiceId,
step: 'ready_to_submit',
changedFields,
});
}
return (
<SafeArea className="p-4 font-sans">
<form className="space-y-4" onSubmit={(event) => event.preventDefault()}>
<label className="block">
<span className="text-sm font-medium">Due date</span>
<input
className="mt-1 w-full rounded-md border px-3 py-2"
type="date"
value={draft.dueDate}
onChange={(event) => updateDueDate(event.currentTarget.value)}
/>
</label>
<button type="button" onClick={markReady}>
Review changes
</button>
</form>
</SafeArea>
);
}
The example syncs only invoiceId, step, and changedFields to app state. The model does not need a copy of every draft field while the user is still typing. When the user reaches a stable step, the app can expose a compact summary.
That keeps the context useful instead of noisy. It also avoids sending sensitive or incomplete draft text into model context before the user is ready.
Submit Through an App-Only Tool
When the user clicks Save, the resource should call a server tool through the host bridge. In sunpeak, the React wrapper is useCallServerTool. In low-level MCP Apps, the same idea is a UI-initiated tools/call.
import { z } from 'zod';
import type { AppToolConfig, ToolHandlerExtra } from 'sunpeak/mcp';
export const tool: AppToolConfig = {
title: 'Save Invoice Changes',
description: 'Save confirmed invoice form changes after the user submits the form.',
annotations: {
readOnlyHint: false,
destructiveHint: false,
openWorldHint: true,
},
_meta: {
ui: {
visibility: ['app'],
},
},
};
export const schema = {
invoiceId: z.string(),
dueDate: z.string(),
amountCents: z.number().int().positive(),
expectedVersion: z.string(),
};
export default async function saveInvoiceChanges(
args: z.infer<z.ZodObject<typeof schema>>,
extra: ToolHandlerExtra
) {
requireScope(extra.authInfo, 'invoices:write');
const saved = await saveInvoice(args);
return {
content: [{ type: 'text' as const, text: `Saved invoice ${saved.number}.` }],
structuredContent: {
invoiceId: saved.id,
number: saved.number,
status: saved.status,
savedAt: saved.savedAt,
},
};
}
_meta.ui.visibility: ['app'] keeps the submit helper out of the model-facing tool list. The model can open the review form. The user submits the final form from the iframe.
This is a good default for buttons like Save, Next page, Validate, Refresh, Retry, Cancel job, and Load preview. The model should not have to choose those helper tools from a prompt. The rendered UI owns them.
In ChatGPT-only code, you may see window.openai.callTool. OpenAI documents window.openai as an Apps SDK compatibility layer and optional ChatGPT extension. For new cross-host apps, keep the portable bridge or a framework hook as the main path, then add ChatGPT-specific APIs only where the standard path does not cover the feature.
Validate in Three Places
Client-side validation is for speed. Server-side validation is for trust.
Use all three layers:
| Layer | What it catches | Example |
|---|---|---|
| Client form validation | Required fields, local formatting, disabled submit state | Missing date, invalid email, negative amount |
| MCP input schema | Malformed requests at the tool boundary | amountCents is not an integer |
| Tool handler validation | Auth, scopes, conflicts, quotas, stale data, business rules | Invoice version changed after the form loaded |
Do not rely on the model to validate form input. Do not rely on the iframe either. Users can submit stale forms, hosts can replay tool calls, and model-generated arguments can be malformed. The server handler is the authority.
Return validation errors as structured data the resource can render.
return {
isError: true,
content: [{ type: 'text' as const, text: 'The invoice could not be saved.' }],
structuredContent: {
code: 'validation_failed',
fieldErrors: {
dueDate: 'Due date must be in the future.',
amountCents: 'Amount must be greater than zero.',
},
},
};
Keep the error user-facing and recoverable. Do not return stack traces, SQL errors, raw validation library dumps, access tokens, or private upstream payloads.
Design Approval Flows as Preview Then Commit
Approval forms need a stronger contract than ordinary forms.
A useful pattern:
- A model-visible read-only tool prepares the proposal.
- The resource renders the proposal and editable fields.
- The user reviews the details in the iframe.
- The user clicks an explicit submit action.
- An app-only write tool validates and commits the change.
- The resource updates model-visible app state with a short submitted summary.
For destructive work, add both host-level and in-app guardrails. Use honest readOnlyHint, destructiveHint, and openWorldHint annotations. Show clear in-app copy on the submit button. Consider a confirmation checkbox or typed confirmation when the action deletes, sends, publishes, purchases, or changes other users’ data.
OpenAI’s security guidance for Apps SDK says to use least privilege, explicit user consent, and defense in depth. That advice is especially relevant for forms because forms are where read-only browsing becomes write-capable software.
Multi-Step Forms Need a Step Contract
Multi-step flows break when the current step only exists in local React state.
Use local state for draft fields, but sync the durable step when it matters:
const [workflow, setWorkflow] = useAppState({
step: 'details',
selectedAccountId: null as string | null,
readyToSubmit: false,
});
function goToReview(selectedAccountId: string) {
setWorkflow({
step: 'review',
selectedAccountId,
readyToSubmit: true,
});
}
The model does not need the full form history. It needs the current step, stable IDs, and a compact summary of what the user decided. If the user asks “submit that now,” the model and server should be able to tell what “that” means without guessing from hidden DOM state.
For longer workflows, persist server-side draft IDs. App state is scoped to the rendered resource and host conversation. It is not a replacement for a database record when a workflow must survive reloads, multiple devices, or human approval delays.
Layout and Accessibility Matter More in Forms
Forms put more pressure on host constraints than cards do. Inline mode may be narrow. Fullscreen may have different safe areas. Mobile keyboards reduce the visible viewport. Host theme changes can break contrast. A form that technically renders can still be hard to use.
Use this checklist:
- Every input has a visible label.
- Validation errors are associated with fields.
- The first invalid field receives focus after submit.
- Submit buttons have loading and disabled states.
- Keyboard users can reach every field and action.
- The form works in inline and fullscreen layouts.
- Error and success messages use
role="alert"orrole="status"where appropriate. - Draft state survives display mode changes where the host supports state restoration.
If the form is dense, do not force the whole workflow into inline mode. Render a compact summary inline and request fullscreen for the editable form when the host supports it. If fullscreen is unavailable, keep a usable inline fallback.
Test the States Users Will Hit
Do not test only the happy path. Form bugs live in state transitions.
Create simulations for:
- Initial form loaded from
structuredContent. - Dirty draft with unsaved edits.
- Client-side invalid fields.
- Server validation failure.
- Auth expired.
- Conflict because the record changed.
- Submitting state with disabled controls.
- Successful save.
- User cancellation.
- Reloaded resource with restored app state.
Then add a simulation for the app-only submit tool:
{
"tool": "review_invoice_changes",
"userMessage": "Review invoice INV-042",
"toolInput": {
"invoiceId": "INV-042"
},
"toolResult": {
"content": [{ "type": "text", "text": "Opened an editable review form for invoice INV-042." }],
"structuredContent": {
"invoiceId": "INV-042",
"number": "INV-042",
"customerName": "Acme",
"amountCents": 12000,
"dueDate": "2026-08-01",
"status": "open"
}
},
"serverTools": {
"save_invoice_changes": [
{
"when": {
"invoiceId": "INV-042",
"dueDate": "2026-08-15"
},
"result": {
"content": [{ "type": "text", "text": "Saved invoice INV-042." }],
"structuredContent": {
"invoiceId": "INV-042",
"number": "INV-042",
"status": "open",
"savedAt": "2026-07-20T12:00:00Z"
}
}
}
]
}
}
Then add an inspector E2E test for the real interaction:
import { expect, test } from 'sunpeak/test';
test('invoice form validates and saves through app-only tool', async ({ inspector }) => {
const result = await inspector.renderTool('review_invoice_changes', {
invoiceId: 'INV-042',
});
const app = result.app();
await app.getByLabel('Due date').fill('2026-08-15');
await app.getByRole('button', { name: 'Review changes' }).click();
await expect(app.getByText('Due date changed')).toBeVisible();
await app.getByRole('button', { name: 'Save changes' }).click();
await expect(app.getByRole('status')).toHaveText(/Saved invoice INV-042/);
});
The exact test API depends on your framework, but the target is stable: render the resource in a host-like iframe, fill the form like a user, mock the app-only server tool, and assert the visible result.
With sunpeak, that loop runs locally in the MCP App Inspector. You can switch ChatGPT and Claude host modes, themes, display modes, viewport sizes, tool results, and app state without using live host credits. Keep live ChatGPT or Claude testing for one or two release smoke tests after local coverage is green.
A Practical Build Order
Build forms in this order:
- Define the read-only tool that opens the form.
- Define the
structuredContentthe resource needs for initial values. - Render the form with local draft state and client validation.
- Sync only stable decisions and step state to app state.
- Add the app-only submit tool with a strict input schema.
- Add server-side validation and conflict handling.
- Add simulations for every state.
- Add inspector E2E tests for fill, validate, submit, and failure.
- Add one live host smoke test for the production path.
That order keeps the app contract clear. The model opens the form. The user edits the form. The app submits through a tool. The server validates and saves. The model receives the short state it needs for the next turn.
Where sunpeak Fits
sunpeak helps most when the form has more than one state. The local inspector lets you pin structuredContent, _meta, app state, display mode, host theme, and server tool responses, then replay them in Playwright.
That means you can test the hard parts of MCP App forms locally:
- Does the resource render from the same tool result the model sees?
- Does the app-only submit tool stay hidden from the model-facing list?
- Does validation render inside the iframe without leaking server details?
- Does app state tell the model the current step without dumping the whole draft?
- Does the form still work in ChatGPT and Claude host modes?
Start with npx sunpeak new for a new MCP App, or use npx sunpeak inspect --server http://localhost:8000/mcp if you already have an MCP server. Add the form state simulations before the UI feels finished. They will save you from retesting the same edge cases by hand in live hosts.
Get Started
npx sunpeak newFurther Reading
- MCP App actions - callServerTool, sendMessage, and updateModelContext
- Interactive MCP Apps with useAppState - two-way UI state
- MCP App model context and UI state
- Testing MCP App data flow - content, structuredContent, _meta, and host bridge state
- Accessibility testing for MCP Apps
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- OpenAI Apps SDK - design components
- OpenAI Apps SDK - component bridge reference
- OpenAI Apps SDK - build a ChatGPT UI
- MCP Apps overview
Frequently Asked Questions
Can an MCP App render a form inside ChatGPT or Claude?
Yes. An MCP App resource is a sandboxed iframe, so it can render normal HTML or React form controls. The important part is how the form connects back to the MCP server and model context. Keep local draft fields in component state, sync only useful decisions or selections to app state, and submit through a server tool when the form needs backend validation or writes.
Should MCP App form fields live in useState or useAppState?
Use local React state for draft field values the model does not need to see yet. Use app state, such as sunpeak useAppState or the MCP Apps update model context bridge, for stable facts the model should know on follow-up turns: selected IDs, current step, confirmed choices, validation status, or submitted draft summaries. Do not sync every keystroke to the model.
How should a ChatGPT App submit a form?
For portable MCP Apps, submit through the MCP Apps bridge with a UI-initiated tool call, often wrapped by a framework hook such as useCallServerTool. Mark submit-only helper tools as app-only with _meta.ui.visibility set to ["app"]. ChatGPT also supports window.openai.callTool as a compatibility API, but new cross-host apps should keep the standard MCP Apps path as the main contract.
Where should form validation happen in an MCP App?
Validate in three places. Use client-side validation for fast feedback and disabled buttons. Use the tool input schema to reject malformed requests at the MCP boundary. Use server-side business validation inside the tool handler for permissions, freshness, conflicts, quotas, and writes. Never trust iframe state or model-generated arguments as already valid.
How do approval forms work in MCP Apps?
Approval forms should separate preview from commit. The model-visible or read-only tool prepares a proposed action and renders the form. The user reviews and edits the form inside the resource. A separate app-only submit tool performs the write only after the user clicks the explicit action. For destructive work, use honest tool annotations and host confirmation prompts in addition to in-app confirmation UI.
How do I test MCP App forms?
Create simulation files for initial, dirty, invalid, valid, submitting, server-error, conflict, confirmed, and cancelled states. Add unit tests for form reducers and validation helpers. Add inspector E2E tests that fill fields, tab through controls, submit the form, mock app-only tools, and assert both UI state and model-visible app state. Keep a small live host smoke test for final ChatGPT or Claude validation.
What is the biggest mistake in MCP App form design?
The biggest mistake is treating the iframe like a normal web app and posting directly to an ad-hoc API. That bypasses the MCP tool contract, makes model context drift from UI state, and is harder to test across hosts. Route meaningful form actions through tools, keep schemas tight, and test the bridge path before relying on a live host.