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 for a consequential MCP action: OAuth access, host tool permission, exact-effect human review, and server-side enforcement
Figure 1. The four-layer model I use in production MCP apps. The first two layers map to documented protocol and host mechanisms. Exact-effect review and proposal binding are my implementation pattern. Original diagram, last checked July 20, 2026.

Four authorization layers, not one consent blob

01 · DOCUMENTED

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.

02 · DOCUMENTED

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.

03 · IMPLEMENTATION JUDGMENT

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.

04 · IMPLEMENTATION JUDGMENT

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.

Gmail Compose MCP app preview showing the exact recipient, subject, message body, and a Send action
Figure 2. A firsthand capture from my Gmail Compose MCP app. The review surface shows the fields that determine the effect and labels the action “Send.” Interface captured May 25, 2026. See the Gmail Compose implementation.

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.

Google Sheets Diff MCP app showing a highlighted spreadsheet patch, target range, pending state, and Apply button
Figure 3. A firsthand capture from Sheets Diff. The target range, highlighted proposal, pending state, and “Apply” action make the spreadsheet effect inspectable before commit. Interface captured June 2026. See the production case study.

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.

Comparison between exact-effect approval for individual MCP writes and a trajectory supervisor that watches a complete long-running agent session
Figure 4. Exact-effect approval and trajectory supervision solve different problems. The action gate is the production pattern used in my MCP apps; the run-level controls are an original implementation model informed by OpenAI's July 20, 2026 long-horizon report. Original diagram, last checked July 21, 2026.
RUN CONTRACT

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.

TRAJECTORY LEDGER

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.

OPERATOR CONTROL

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.

INCIDENT LOOP

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 attemptExpected server responseBoundary being proved
Valid OAuth token, no proposalReject the commitAccount access is not action approval
Commit contains new recipient or valuesReject unknown fields or ignore themOnly the reviewed proposal can execute
Proposal belongs to another userReject without revealing proposal contentsApproval is bound to identity
Source changed after previewExpire and regenerate the previewApproval is bound to inspected state
Duplicate click or host retryReturn the original receiptOne approval produces at most one effect
OAuth scope revoked before commitFail closed and reauthorizeApproval cannot bypass current access policy
Allowed calls collectively exceed the run contractPause the run and invalidate pending proposalsAction approval is not trajectory approval
Repeated denied calls return with obfuscated argumentsEscalate the sequence, not only the latest callThe 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

Continue reading