Skip to content

Approval Flow

Agents increasingly take consequential actions, so they need a clean way to pause and ask a human — confirm, approve — and then continue. MCP’s URL-mode elicitation can push a native “open this link?” prompt to the host, but host support for it is inconsistent.

Write the approval skill once; the server adapts. Your skill always makes the same calls — request_approval, then await_approval, then apply. Underneath, the server pushes a URL-mode elicitation prompt only to hosts that advertise support for it; every host reaches the same outcome through the same long-poll.

Diagram

Four tools, each returning its result as JSON. Shapes match the server handlers exactly.

request_approval — record an approval request and (best-effort) push a URL-mode prompt to capable hosts.

Input: { "purpose": "Deploy to production", "payload": { … }, "ttlSeconds": 3600 }
Output: { "elicitationId": "…", "status": "pending", "reviewUrl": "https://…/review?elicitationId=…",
"expiresAt": "2026-07-16T12:00:00.000Z", "elicitationDelivered": true }
  • payload is the exact thing that will be executed on approval — the server stores it and hands it back from apply, so the human approves what actually runs.
  • elicitationDelivered is true only when the host advertised the elicitation: { url: {} } capability. The prompt is non-blocking; reviewUrl is always returned so any host can reach the review page.
  • ttlSeconds is optional (default 3600, clamped 6086400).

await_approval — block until the request is decided, or the wait bound elapses.

Input: { "elicitationId": "…" }
Output: { "status": "approved" } // or "rejected" | "pending" | "applied" | "expired"
  • A bounded long-poll: waits up to min(server bound, time until expiry), returning the instant a decision lands. If it returns "pending", the wait bound was hit before a decision — just call it again (see below).

check_status — a non-blocking status read.

Input: { "elicitationId": "…" }
Output: { "status": "pending" } // pending | approved | rejected | applied | expired

apply — execute an approved request; returns the stored payload.

Input: { "elicitationId": "…" }
Output: { "status": "applied", "payload": { … } }
  • Succeeds only once the request is approved. The returned payload is the one stored at request time, never a value the agent re-supplies.
Diagram

The skill’s column never changes. The URL push is the only capability-dependent step, and both paths converge on the same await_approval wake.

await_approval is bounded so it always returns before the host’s tool-call timeout. Two ways it returns:

  • A decision landed — the reviewer approved or rejected; the call wakes immediately with that status.
  • The bound elapsed — it returns "pending"; call await_approval again to keep waiting.

That single loop works everywhere: on a URL-capable host the reviewer likely acts on the pushed prompt; on any other host they open reviewUrl — either way the same wake resolves the same loop.

Every request carries a TTL (ttlSeconds, default 1 hour, clamped 60 seconds to 24 hours). Once it passes, the request is expired — a terminal state. await_approval and check_status both report "expired" at the deadline, and the review page refuses a late decision. Ask the agent to resend if that happens.