# What happens when an instance treats MCP elicitation as a synchronous confirmation rather than a durable capability grant **Date of Record:** 2026 November 13 **Scope:** Applies to MCP (Model Context Protocol) hosts and servers implementing `elicitation/create` for human-in-the-loop approval of high-risk tool invocations, specifically when the approved action is enqueued after approval rather than executed within the elicitation round-trip. Does not apply to synchronous approvals where the tool call completes inside the same request context that issued the elicitation, nor to purely advisory elicitations with no side-effecting follow-up. ## Core Distinction There are two ways an MCP client can interpret a successful `elicitation/create` response of `{"action": "accept", "content": {...}}`: 1. **Synchronous confirmation** — the human approved *this* invocation, and the server proceeds to execute it within the same logical operation. The grant's lifetime is the request. 2. **Durable capability grant** — the human approved a *capability* bound to specific arguments, and the server may execute that capability later, possibly after the elicitation request has returned, possibly after the transport connection that carried it has closed, possibly after a restart. These interpretations diverge precisely when the approved action is deferred: queued behind a rate limit, scheduled for a maintenance window, retried after a transient failure, or executed by a worker pool separate from the request handler that elicited approval. The protocol does not mandate which interpretation is correct. The failure mode described here occurs when a server is written under interpretation 1 but its execution path is structurally interpretation 2. ## What Was Observed A deployment approved a high-risk `filesystem.delete` invocation via elicitation. The elicitation returned `accept` with a content payload echoing the target path and a nonce. The server enqueued the deletion onto a durable work queue (because the underlying storage backend serialized destructive operations behind a lock) and returned `200 OK` to the client. The client, having received a successful tool result, considered the approval consumed. Forty-one minutes later the queue drained the job. A retry wrapper — installed earlier for unrelated transient backend errors — re-executed the job after a lock timeout. The original elicitation had long since been garbage-collected from the host's pending-request table. The retry had no elicitation context and no way to re-request one, because the originating client connection had closed. The worker executed the deletion anyway, because the job payload contained the approved arguments and the worker treated "job present in queue" as sufficient authorization. The deletion succeeded. The audit log recorded one approval and one deletion. The retry recorded a second deletion attempt against a path that had, in the intervening minutes, been recreated by an unrelated process. The invariant that broke: **an approval token whose validity is scoped to a request context was used to authorize an execution whose lifetime exceeded that context, and the retry path re-derived authorization from the durable payload rather than from the original grant.** ## Condition of Validity The synchronous-confirmation interpretation is valid only when all of the following hold: - The approved action is executed within the request handler that issued the elicitation, before the handler returns. - No retry, queue, or deferral path can re-execute the action. - The elicitation's `content` payload is treated as a *description* of what was approved, not as a *bearer credential* that authorizes execution. The durable-grant interpretation is required when any of the following hold: - Execution is deferred past the elicitation response (queue, schedule, batch). - Execution may be retried, and retries are not provably idempotent against the same approved arguments. - Execution occurs on a worker whose lifetime is not bounded by the elicitation's request context. - Multiple executions may be derived from one approval (fan-out, sharded operations). Most real deployments of destructive or financial tools satisfy at least one of these. The boundary is non-obvious because the elicitation API surface looks identical in both cases: a single request, a single response, a boolean-ish `action` field. ## Decision Boundary The decision is not "should I use elicitation for this tool." It is: **at what point does the approval stop being a property of the request and start being a property of the payload?** If the answer is "never — the payload carries only descriptive content and the approval is re-validated at execution time against a live grant record," you are in the durable-grant regime and must implement grant persistence: a server-side record keyed by a grant ID, with an explicit expiry, an explicit consumption marker, and a binding to the exact argument hash approved. The worker must present the grant ID and argument hash; the server must atomically check-and-consume; a retry after consumption must fail closed. If the answer is "the payload carries the approval and execution happens in-context," you are in the synchronous regime and must ensure no deferral path exists. The moment a queue, retry wrapper, or worker pool is introduced between elicitation and execution, the regime has silently changed and the code has not. The dangerous middle — payload-as-bearer-credential with no server-side consumption check — is the configuration that produced the observation above. It looks like the synchronous regime (the payload is self-contained) but behaves like the durable regime (the payload outlives the request). There is no protocol-level signal that you have crossed this boundary. ## Operational Effect The practical consequences of crossing the boundary without implementing grant persistence: - **Double execution.** Retries re-execute approved actions because authorization is re-derived from the payload, which is stable across retries. Idempotency keys help only if the destructive backend honors them; filesystem and many cloud APIs do not. - **Stale approval.** A grant approved at T is executed at T+41m against state that has changed. The human approved a deletion of path P as it existed at T; the worker deleted P as it existed at T+41m. These are different operations. - **Unauditable provenance.** The audit log shows one approval event and N execution events with no linkage. Reconstructing which execution was authorized by which approval requires correlating timestamps, which fails under retry. - **No expiry enforcement.** The elicitation response has no built-in TTL. Hosts may garbage-collect pending requests, but the payload in the queue is unaffected. A grant approved on Monday can execute on Friday. The corrective pattern is to treat the elicitation `content` as an opaque *reference* (a grant ID plus argument hash), never as the authority itself. The authority lives in a server-side table with `(grant_id, arg_hash, expires_at, consumed_at)`. Execution atomically transitions `consumed_at` from null to a timestamp; a second attempt with the same grant ID raises. Expiry is enforced at consumption, not at enqueue. Retries carry the grant ID and fail closed after first consumption unless the operation is explicitly declared idempotent and the grant is explicitly declared multi-use (which should be rare and loud). The non-obvious part is that this requires a stateful check at the worker, which is far from the elicitation site, and that the check must be atomic with execution. A check-then-execute with a gap reintroduces the race. The grant table must be the serialization point for the side effect, or the side effect must be idempotent under the grant ID. ```python import hashlib import time from dataclasses import dataclass from typing import Optional class GrantViolation(RuntimeError): """Raised when an execution attempt does not hold a valid, unconsumed grant.""" pass @dataclass class Grant: grant_id: str arg_hash: str expires_at: float consumed_at: Optional[float] = None class GrantStore: """Server-side authority for elicitation-derived approvals. The elicitation response content carries only grant_id and arg_hash. Authority lives here. Consumption is atomic with the side effect. """ def __init__(self) -> None: self._grants: dict[str, Grant] = {} def issue(self, grant_id: str, args: dict, ttl_seconds: float) -> str: arg_hash = hashlib.sha256( repr(sorted(args.items())).encode() ).hexdigest() self._grants[grant_id] = Grant( grant_id=grant_id, arg_hash=arg_hash, expires_at=time.monotonic() + ttl_seconds, ) return arg_hash def consume(self, grant_id: str, args: dict, now: Optional[float] = None) -> None: now = time.monotonic() if now is None else now grant = self._grants.get(grant_id) if grant is None: raise GrantViolation(f"unknown grant_id={grant_id!r}") if grant.consumed_at is not None: raise GrantViolation( f"grant_id={grant_id!r} already consumed at {grant.consumed_at}" ) if now >= grant.expires_at: raise GrantViolation( f"grant_id={grant_id!r} expired at {grant.expires_at}" ) arg_hash = hashlib.sha256( repr(sorted(args.items())).encode() ).hexdigest() if arg_hash != grant.arg_hash: raise GrantViolation( f"grant_id={grant_id!r} argument hash mismatch: " f"approved {grant.arg_hash[:12]} got {arg_hash[:12]}" ) grant.consumed_at = now def execute_destructive(store: GrantStore, grant_id: str, args: dict) -> None: """Worker-side entry point. Fails closed on retry, expiry, or arg drift.""" store.consume(grant_id, args) # Side effect proceeds only after atomic consumption. A retry after this # point raises GrantViolation rather than re-executing. _perform_delete(args["path"]) def _perform_delete(path: str) -> None: # Placeholder for the actual destructive backend call. pass ```