Skip to content

Community-maintained FDE reference. Not an official Vercel or Anthropic project. About this project

Consent UX

Audience:engineerarchitectsecuritynon-technicalMCP spec 2026-07-28

TL;DR: Consent is the host’s job. The user trusts the host, not the servers behind it, so the host is the only place an approval prompt can meaningfully live. A server can declare that a tool is destructive (through tool annotations), but only the host can gate it: show the user what is about to happen and require approval before dispatch. The gate must be fail-closed: anything other than an explicit approval denies the call. And under MCP 2026-07-28 the gate has one more place to stand: when a server answers with resultType: "input_required", the retry that carries the user’s input back is a dispatch like any other, and it passes the same gate. When every server is a remote deployment at a URL, as it is on Vercel, this gate is the user’s whole defense, because there is no local install step where trust was ever established.

Plain-language explanation

When the model decides to call a tool, something has to stand between “the model wants to send this email” and “the email is sent.” That something is the host’s consent step. The trust gradient in MCP is steep: the user trusts the host, the host treats every server as untrusted external code, and the model’s tool choices are influenced by content that may itself be adversarial (a prompt-injection payload hiding in a tool result or a web page). The spec is direct about this: applications SHOULD keep a human in the loop with the ability to deny tool invocations, and SHOULD show tool inputs to the user before the call goes out. A consent prompt is the user’s one chance to catch an action they never intended.

The split of responsibilities matters. The server’s job is to describe its tools honestly; the host’s job is to decide when to interrupt the user and what to show. A server cannot be trusted to gate its own destructive actions, because a compromised server would simply decline to.

There is a serverless twist worth naming early: a deployed server can change under you. The code behind https://tools.example.com/api/mcp is whatever its owner most recently deployed. A tool that was read-only yesterday can be destructive today, at the same name, on the same URL. Consent decisions therefore need to be bound to what was approved, not merely to a name.

When to prompt

Not every call deserves a prompt. Too many prompts and users click through all of them, which is its own failure mode. Calibrate friction to risk:

  • Always gate state-changing or irreversible actions: anything a server marks destructive, anything that writes, sends, deletes, pays, or reaches an open-ended external system.
  • Gate the first use of a newly connected server, so the user understands what the model was just given access to.
  • Gate the MRTR retry. Under 2026-07-28, Multi Round-Trip Requests (MRTR) replace server-initiated requests (roots/list, sampling/createMessage, elicitation/create): the server returns an InputRequiredResult (resultType: "input_required") whose inputRequests carry what it needs, the client retries the original request with inputResponses, and the server correlates the retry via requestState (SEP-2322). The retry sends user-supplied input to the server and re-executes the request, so it gets the same review the first dispatch got: show the user what the server asked for and what is about to go back; see Elicitation.
  • Gate sampling wherever it still appears. Sampling is deprecated in 2026-07-28 (SEP-2577; the suggested migration is direct LLM provider APIs), but hosts will meet older servers that request it for at least the deprecation window. A server asking the host’s model to generate text is a capability the user should approve, with the prompt text visible for review; see Sampling-request handling.
  • Re-prompt when a tool’s definition changes. If the description, schema, or annotations of an approved tool differ from what the user saw, the old approval is stale. Redeploys make this a routine event, not an edge case.
  • Auto-approval is reasonable for read-only, idempotent queries under a policy the user explicitly opted into. It must never be a silent default.

What to show the user

An approval prompt the user cannot evaluate is theater. Show enough for a real decision:

  • Which server the tool belongs to: the namespaced identifier (mail.send, not bare send), so the origin is unambiguous across a composed surface.
  • The tool and its description, plus whether it is marked destructive or irreversible.
  • The actual arguments the model chose: the email body, the file path, the amount. Not just the tool name. The arguments are where an injected instruction surfaces.
  • On an input_required round: what the server asked for and what will be sent back. The inputRequests are server-authored content and the inputResponses may carry the user’s own words; both belong in the prompt.
  • The scope the approval grants: this call only, this conversation, or a standing allowance.

Fail-closed by default

The gate’s default must be denial. An indeterminate decision (a consent callback that throws, returns undefined, times out, or was never wired up) denies the call. This is the single most common place real implementations go subtly wrong: a check written as “deny only if the answer is exactly no” dispatches on null, on an exception, and on a timeout. Write the gate so that only an explicit, affirmative approval proceeds, and test the malformed cases.

noyesyes (scoped,revocable)no / timeout / error/ unknowncompleteinput_requiredModel selects a toolcallDestructive or notyet approved?Dispatch to theowning serverHost prompts:server, tool,arguments,irreversibilityExplicit approval?Deny (fail-closed)resultType on theresult?Result to therendering layerHost reviewsinputRequests,gathers user input
noyesyes (scoped,revocable)no / timeout / error/ unknowncompleteinput_requiredModel selects a toolcallDestructive or notyet approved?Dispatch to theowning serverHost prompts:server, tool,arguments,irreversibilityExplicit approval?Deny (fail-closed)resultType on theresult?Result to therendering layerHost reviewsinputRequests,gathers user input
Mermaid flowchartOpen in Mermaid Live Editor
Diagram source (Mermaid)
flowchart TB
    call["Model selects a tool call"] --> q{"Destructive or not yet approved?"}
    q -- no --> run["Dispatch to the owning server"]
    q -- yes --> prompt["Host prompts: server, tool, arguments, irreversibility"]
    prompt --> dec{"Explicit approval?"}
    dec -- "yes (scoped, revocable)" --> run
    dec -- "no / timeout / error / unknown" --> deny["Deny (fail-closed)"]
    run --> res{"resultType on the result?"}
    res -- complete --> done["Result to the rendering layer"]
    res -- input_required --> gather["Host reviews inputRequests, gathers user input"]
    gather --> q

The loop at the bottom is the 2026-07-28 addition. An input_required result is not an answer; it is the server asking for another round trip. The retry re-enters the gate at the top: it inherits every property the first dispatch had (fail-closed, argument-visible, per-server), plus one of its own, because the inputResponses it carries may be sensitive user input that did not exist when the call was first approved. The server correlates the rounds via requestState; whether the round trip happens at all is the host’s decision, and an unreviewable or unexpected inputRequests payload is denied like any other indeterminate case. Wire status: the pinned stack (mcp-handler 2.1.1 on @modelcontextprotocol/server 2.0.0) serves the 2026-07-28 contract natively over Streamable HTTP and falls back to stateless 2025-11-25 Streamable HTTP for legacy clients; the SDK Client defaults to that legacy handshake unless you opt in to modern version negotiation, so the examples’ in-memory test suites exercise only the legacy path.

Approval scope and revocation

“Approve once” is safe but noisy; “always allow” is convenient but dangerous. A good host offers scoped grants and keeps them honest:

  • Per call - the default for destructive actions. A call and its input_required retries form one user-visible action, but the retry still surfaces what is being sent back.
  • Per conversation - a time-bounded allowance scoped to the host-side conversation or connection. 2026-07-28 removed the protocol-level session, so this scope is purely host state: nothing on the wire delimits it for you. Bound it explicitly (in time, or to one conversation), and do not let a re-established connection to the same URL silently inherit the grant, because the deployment behind that URL may have changed.
  • Standing (“always allow”) - only if it is opt-in, scoped to one tool on one server, bound to the tool definition it was granted against, time-bounded, and revocable from a visible control.

Two rules hold across every scope. Approval is per server: never infer consent for server B from a grant to server A. Approval is per tool: approving files.read is not approval for files.delete. See trust boundaries for why approval state must never cross the server boundary.

The host’s UI consent has a protocol-level counterpart in authorization. Under MCP 2026-07-28, a server that needs more permission than the client’s token carries does not fail opaquely: an unauthenticated request gets a 401 whose WWW-Authenticate header SHOULD name the scopes required, and a request with an insufficient token gets a 403 with error="insufficient_scope" and the scopes needed for that operation. The client then runs a step-up authorization flow for just those scopes rather than requesting everything up front. This is the consent principle applied to OAuth: grant the minimum now, escalate only when a specific operation demands it, and make each escalation a deliberate, user-visible step. The mechanics (challenge parsing, RFC 9728 metadata discovery, token exchange) live on the credential-brokering page; from this page’s vantage, a scope challenge is another moment to show the user a clear, narrow approval instead of pre-authorizing a broad one. On the server side of this repository’s stack, withMcpAuth with requiredScopes is what emits those challenges; see Authorization.

Annotations are hints, not authority

Tools can carry annotations: readOnlyHint, destructiveHint, idempotentHint, openWorldHint (see capability primitives). They are useful for calibrating friction: a readOnlyHint: true tool is a candidate for policy-based auto-approval, a destructiveHint: true tool always gets the full prompt. But the spec is explicit that clients MUST consider tool annotations untrusted unless they come from a trusted server, and a remote deployment you do not operate is not a trusted server. Never let a missing or false destructiveHint downgrade a gate you would otherwise apply: treat unknown or unannotated tools as needing approval, and use annotations only to add friction, never to remove it.

Common pitfalls

  • Trusting annotations blindly. A compromised server can claim every tool is readOnlyHint: true. Annotations raise friction; they must never silently remove the gate.
  • Fail-open gates. A consent check that allows on anything-but-an-explicit-no dispatches on null, exceptions, and timeouts. Default to deny, and assert it in tests.
  • Gating the first dispatch but not the retry. An input_required retry that skips the gate ships user input to the server with no approval; the retry is a dispatch like any other.
  • Inferring consent across servers or tools. One shared “always allow” across a composed surface defeats per-server isolation.
  • Hiding the arguments. Approving by tool name alone lets an injected argument through unseen.
  • Approvals that survive redeploys. A standing grant keyed only on a tool name follows the name to whatever code ships next. Key grants to the definition, not the label.
  • Prompt fatigue. Gating every read-only query trains users to click approve reflexively, so the one prompt that matters gets approved too. Calibrate to risk.

Example implementation

  • examples/orchestrator-host (in the repository) - the host gates destructive calls behind a single fail-closed consent callback applied before dispatch, kept distinct from any server-side authorization. Its tests prove the load-bearing negatives: a denying callback blocks the call with no state change, a malformed or throwing callback also blocks (fail-closed means indeterminate is a no), and a read-only tool is not gated.
  • examples/secure-tools-server (in the repository) - the server half of the same story: a write tool that declares itself honestly so a real host can gate it. Server-declared intent plus host-enforced gate is the pair this page is about.
  • Multi-server composition - why approval is per server and the namespaced identifier belongs in the prompt.
  • Elicitation - the input_required round from the input-gathering side: form mode, URL mode, and the never-elicit-secrets rule.
  • trust-boundaries - why the host is the only consent surface and approval state must not cross the server boundary.
  • Where the principal comes from - consent (does the user approve?) is distinct from authorization (is this principal allowed?).
  • Authorization - the OAuth flow that scope challenges escalate through, and its Vercel wiring.
  • query-vs-command - the query/command split is what lets a host apply low friction to reads and strong consent to writes.
  • least-privilege - “always allow” and bulk approvals as a least-privilege concern.
  • Consent & user approval - the printable checklist items this page expands.

Bibliography