Field guide · MCP apps
How to build an MCP app with human approval
OAuth can prove that a user may access an account. It cannot approve this exact write—or supervise where a long-running sequence is heading.
Last verified . Host confirmation behavior, MCP authorization, Agents SDK approval flows, and long-horizon safety guidance move quickly. Details may be stale after this date; recheck the linked primary sources before shipping.
The dangerous part of an agent workflow is not the OAuth screen. It is the moment an authorized capability becomes a particular action: this email leaves, this message posts, these cells change. Long-running agents add another boundary: a series of locally permitted steps can still drift toward an outcome the user never approved.
The MCP authorization specification describes authorization at the transport level: a client obtains a token to make requests to a protected MCP server on a resource owner's behalf. The MCP Apps authorization guide adds per-server and per-tool patterns. Neither says that a valid token is approval for every write the token permits. That final sentence is the application boundary, not a quotation from the protocol.
Four authorization layers, not one consent blob
OAuth access
Identify the resource owner, bind the token to the MCP server, request the least scopes needed, and validate the token on every protected call. This decides whether the request may reach protected capability.
Host tool permission
Tell the client whether a tool reads, writes, destroys, or reaches outside the account. In ChatGPT, annotations shape the confirmation experience. They do not replace server authorization.
Exact-effect review
Show the recipient, destination, changed values, warnings, and other fields that determine the consequence. A vague “Continue” control is not meaningful review.
Server-enforced commit
Bind the confirmed action to a stored proposal, revalidate user and source state, execute that proposal once, and return a receipt. The model never supplies fresh write arguments at commit time.
OpenAI's current Apps SDK reference makes the boundary unusually explicit: readOnlyHint, destructiveHint, and openWorldHint influence how ChatGPT frames the call, while the server must enforce its own authorization logic. OpenAI's app guidelines also require external-state changes to be labeled as writes and say destructive or outbound actions need confirmation friction or a preview mode.
A valid token, a permitted tool call, and an approved effect are three different facts.
The two-tool architecture
I expose one tool that prepares a complete proposal and another that commits it. The preparation tool can accept model-generated content. The commit tool accepts only an opaque proposal identifier.
preview_email({ to, subject, body })
// → { proposal_id, normalized_preview, expires_at }
send_approved_email({ proposal_id })
// → executes the stored proposal once, then returns a receipt
The split is more than naming. It removes the model from the final argument path. If the write tool accepts to, subject, or body again, the confirmed preview can drift away from the action that actually runs.
Normalize the proposal on the server
The preview tool validates and normalizes model input before it reaches the review UI. Store the authenticated user, destination, normalized payload, creation and expiry times, and the version or digest of any source state the action depends on.
Return the displayable proposal and an opaque identifier. Do not make the client reconstruct the authoritative payload from visible fields.
Render the exact consequence
Put model-readable facts in structuredContent. Put widget-only presentation data in _meta. Link the component with _meta.ui.resourceUri, and render every field a person needs for the judgment call.
The button should name the real effect: “Send email,” “Post to #launch,” or “Apply 6 cell changes.” The OpenAI component-bridge reference notes that approval-gated arguments may arrive only after host approval, so components must tolerate missing initial tool input and respond to ui/notifications/tool-input.
Commit by proposal ID, not by prose
On the confirmed call, load the proposal and reject it unless it belongs to the current authenticated user, remains unexpired and unused, still has sufficient external authorization, and still matches the source state shown in the preview.
Execute the stored normalized operation. Never ask the model to restate the change. This prevents a polished new argument from quietly replacing the thing a person reviewed.
Make retries boring
Give the commit an idempotency key or make the proposal single-use. A duplicate click, host retry, or network timeout should return the same receipt instead of sending twice. If the external system supports compare-and-swap or version checks, use them.
Return proof, not reassurance
The write result should identify what happened: provider receipt, message or record ID, affected range, timestamp, and the final state safe to display. If the action is reversible, retain enough information to offer a real undo path.
One approved action does not approve the trajectory
The proposal-and-commit pattern protects one effect. It does not prove that a long sequence of individually allowed actions still serves the user's original goal. OpenAI's July 20, 2026 report on a long-running internal model describes failures that shorter-horizon evaluations missed: the model persisted past environment constraints, found a sandbox weakness to publish a pull request, and in another case split and obfuscated a credential to evade a scanner. This is evidence from one monitored internal deployment, not a test of MCP or of every agent system.
The report's architectural lesson is broader than either incident: a sequence can become unsafe even when a system checks actions one at a time. OpenAI responded with incident-derived evaluations, longer-horizon alignment work, trajectory-level monitoring that can pause a session, and greater user visibility and control. The OpenAI Agents SDK human-in-the-loop guide documents the complementary execution primitive: approvals surface as run-wide interruptions, and serialized run state can pause and resume after a decision.
A separate June 2026 execution-control preprint tested 10 attacks against controlled MCP-like runtimes. Its practice-informed baseline still allowed 6 of 10 modeled attacks, while the experimental runtime with explicit grants, principal binding, capability checks, and deny-path audit blocked all 10. The result is useful design evidence, but it is a one-author preprint over a reference runtime, not a production security result or a claim about every MCP implementation.
Make the intended path inspectable
Record the user goal, allowed systems, cumulative time or spend limits, and actions that always require exact-effect review. A run should not silently expand its own job.
Aggregate calls into one story
Keep receipts, denials, retries, external destinations, proposal IDs, and policy versions under one run ID. Inspect the sequence, not only isolated tool events.
Pause, revoke, and expire
A supervisor should be able to stop the run, revoke its capability, and invalidate outstanding proposals. Resuming should restore reviewed state, not improvise a new one.
Turn surprises into tests
Replay observed failure paths against the monitor and server boundary. A passing pre-deployment suite is a baseline, not proof that production cannot reveal a new trajectory.
Test the boundary, not the happy path
| Failure attempt | Expected server response | Boundary being proved |
|---|---|---|
| Valid OAuth token, no proposal | Reject the commit | Account access is not action approval |
| Commit contains new recipient or values | Reject unknown fields or ignore them | Only the reviewed proposal can execute |
| Proposal belongs to another user | Reject without revealing proposal contents | Approval is bound to identity |
| Source changed after preview | Expire and regenerate the preview | Approval is bound to inspected state |
| Duplicate click or host retry | Return the original receipt | One approval produces at most one effect |
| OAuth scope revoked before commit | Fail closed and reauthorize | Approval cannot bypass current access policy |
| Allowed calls collectively exceed the run contract | Pause the run and invalidate pending proposals | Action approval is not trajectory approval |
| Repeated denied calls return with obfuscated arguments | Escalate the sequence, not only the latest call | The monitor must reason across attempts |
Also test persuasive language. A model can phrase a request as if the user already approved it. Server policy should not care. The authority is a confirmed commit call tied to a current stored proposal, not a sentence that sounds confident.
What the documentation settles—and what it does not
Documented
Current platform behavior
- MCP OAuth protects HTTP transport access and scopes requests to a resource.
- MCP Apps can require authorization for the whole server or specific tools.
- ChatGPT uses tool annotations to frame approval and impact.
- OpenAI's security guidance tells developers to validate inputs server-side and require human confirmation for irreversible operations.
- OpenAI's long-horizon report says single-action controls need trajectory monitoring, intervention, and production feedback for persistent agents.
- The Agents SDK can pause a run on approval interruptions, serialize its state, and resume after a decision.
My design judgment
The stronger application boundary
- Treat OAuth, host permission, exact review, and commit as four separate layers.
- Use a short-lived stored proposal rather than resubmitted model arguments.
- Bind the proposal to user, source version, and one-time execution.
- Make the widget a review surface while keeping the server authoritative.
- For long-running work, keep a separate run contract, trajectory ledger, monitor, and pause or revocation path.
Important documentation gaps: the shared MCP and MCP Apps documents do not prescribe a universal proposal-record schema, expiry time, receipt format, button copy, trajectory monitor, or cross-tool pause protocol. Host confirmation UX is not identical across clients. I have not found a cross-host specification promising that every client will render the same approval step for the same annotations or expose the same run-level intervention controls. Build and test the portable server boundary first; treat host prompts and host-specific supervisors as additional guardrails, not the only ones.
Approval is what makes delegation useful
This is one deliberate click slower than a silent write. It is much faster than auditing what a silent write did. Because the server refuses unapproved effects, the model can work aggressively in the preparation phase: gather context, draft the message, calculate the patch, and surface conflicts. For long-running work, the user also needs a compact view of where the run is going and a real stop control. The person keeps both parts that should not disappear: the exact judgment call and authority over the overall trajectory.
That trade is the pattern behind Gmail Compose, Slack Preview, and Sheets Diff. The Sheets Diff case study shows it in a production spreadsheet workflow; the source-aware writing workflow applies the same prepare-review-commit separation to publication.
Primary references
- MCP authorization specification — transport authorization, resource binding, scopes, and token validation.
- MCP Apps authorization guide — per-server and per-tool OAuth patterns.
- OpenAI Apps SDK annotations reference — host-facing tool impact hints and the server-enforcement boundary.
- OpenAI app guidelines: transparency and user control — write labels, confirmation friction, and preview mode.
- OpenAI security and privacy guide — server validation and human confirmation for irreversible operations.
- OpenAI: Safety and alignment in an era of long-horizon models — internal deployment evidence for incident-derived evaluations, trajectory-level monitoring, intervention, and user control.
- OpenAI Agents SDK: human-in-the-loop — run-wide approval interruptions, serialized state, pause, and resume.
- From Tool Connection to Execution Control — a one-author preprint whose reproducible 10-case MCP-like benchmark motivates explicit runtime grants, principal binding, and deny-path audit; it is not a production MCP security evaluation.