Skip to content
Contextely
Academy6 min readBy The Contextely Team

Human in the Loop MCP: Why We Hold Writes for Approval

Why human in the loop mcp matters more for writes than for reads, and the exact order our action invoke lifecycle runs in, step by step.

A person reviewing a revenue dashboard on a laptop with a pen in hand, standing in for the human approval step in mcp tool action approval

Photo: RDNE Stock project on Pexels

Key takeaways

Every governance conversation about agents starts with reading, because reading is where the obvious risk lives: a confidential record ending up somewhere it should not. Human in the loop mcp is really about the other half of that conversation. Reading a stale number is annoying, and an agent mutating a real record based on a misread instruction is a categorically worse kind of wrong, one you cannot always undo by re-running the query correctly.

Why writes need a different model than reads

Our retrieval pipeline can afford to be forgiving about mistakes, because a bad ranking just means a worse answer, correctable on the next query. Entitlement there runs as a multiplicative factor: an object the asker should not see scores exactly zero and drops out of the results, folded into the same ranking pass that orders everything else.

That trick does not transfer to writes, and assuming it does is the single most expensive mistake available when adding a write path to an agent-facing system. Retrieval works because it ranks: a zero fits naturally into a sorted list. A mutation does not rank. There is no cut that removes a bad write the way a low score removes an irrelevant search result. So for actions, entitlement has to be a plain gate, checked before the effect happens, not a factor folded into anything.

The order the invoke lifecycle actually runs in

Six steps, in this order, and the order is the security model, not an implementation detail:

  1. Claim the log row. The insert itself is the lock. Because the underlying driver has no interactive transactions, claiming the row first is what lets the tenant's usage counter and its pooled counter move together in one statement, and it is also what guarantees every attempt gets a row, including the ones about to be refused.
  2. Check entitlement. Does this specific caller hold the write scope this specific action requires? Write scopes are a separate namespace from read scopes entirely; a grant that lets someone search a source never implies they may act on it.
  3. Validate the arguments against the action's schema.
  4. Decide on approval. If the action requires it, the run stops here in a pending_approval state. Nothing has happened yet, and critically, nothing has been charged either, because a refusal should be free.
  5. Re-assert entitlement, this time against whoever is approving the run, not whoever originally requested it. A person approving a held action needs their own standing to do so; it does not inherit from the requester.
  6. Charge, then perform the effect. Charging happens last, specifically so that every earlier refusal in this list costs the caller nothing.
"Granting LLMs unchecked autonomy to take action can lead to unintended consequences, jeopardising reliability, privacy, and trust."
OWASP Top 10 for Large Language Model Applications, on excessive agency

Holding a run for approval is the direct, structural answer to that risk for anything that mutates data. It does not rely on a model deciding to be cautious. The system simply does not perform the effect until a person with standing says to.

What does the agent actually see when a run is held?

The reply to action_invoke carries status: "pending_approval". That is the whole signal, and the correct behaviour for an agent receiving it is to report that to the person it is working for and stop there. Not retry in a loop hoping the state changes on its own, and not search its own tool list for a way to push the run through, because approving and rejecting a held run are deliberately not tools. An agent holding an API key genuinely cannot find its own bypass, because the bypass does not exist on that surface at all.

The mistake that taught us why sealed and redacted arguments have to be different things

A pending approval has to show a person what they are about to authorize, without putting a live credential on their screen. So the arguments shown on a review dashboard are redacted: anything named like a secret, config, token, apiKey, gets replaced with the literal string "[redacted]" before it is ever rendered.

The mistake, documented plainly in this codebase's own engineering notes rather than hidden after the fact, was executing a held action from that same redacted copy once it was approved. The run appeared to succeed. It had, technically: it wrote the literal text "[redacted]" into a customer's system, with a real person's approval attached to it, and nothing about the process raised an error, because from the system's point of view a string had been written where a string was expected.

Copy What it is for What must never happen
args_redacted Shown to a person deciding whether to approve Being executed from, ever
args_sealed The arguments as actually sent, encrypted separately, replayed on execution Falling back silently to the redacted copy if this one is unavailable

Table 1: the two copies of a pending action's arguments, and why conflating them once produced a silent failure.

args_sealed is dropped the moment a run reaches a terminal state, so its lifetime is exactly the window it is needed in and no longer. The function that opens it throws rather than fall back to the redacted copy it knows is lossy, because a loud failure here is strictly better than a second occurrence of the original mistake.

Why does the write path get its own scope namespace?

Because reusing read scopes was the shortcut that looked free and was not. Read entitlement synthesises a wildcard grant from certain roles automatically; reusing that mechanism for writes would not just extend existing read access into write access, it would hand every owner and admin account a write wildcard on the day the write path shipped, granted by nobody's explicit decision. Write scopes start empty for every existing credential by default, so nothing gains write rights silently just because a new feature deployed.

Why this is a design pattern, not a feature checkbox

It is worth being precise that a held-for-approval state only works as a guarantee if nothing about the surrounding system can be talked into skipping it. NIST's AI risk management framework frames this kind of control as needing to hold under adversarial and edge-case conditions, not just the happy path a demo exercises, which is exactly why the six-step order above lives in a single function whose ordering is asserted by an automated test, rather than as a convention engineers are trusted to remember.

What this buys you in practice

An actions_list call only ever shows what a given workspace has enabled and what this specific caller is scoped for, which means the honest answer to "what can this agent do to our systems" is always visible and always current, not buried in a config file nobody has read since it was written. And because every invocation, refused or not, is logged before it can be refused, a failed attempt is permanently attributable to the person whose credential made it. That is a genuinely different guarantee from "we log successful writes," and it is the one worth actually checking for before letting an agent anywhere near a mutation.

If you are designing a similar write path, the MCP pillar page covers where this sits relative to read-side entitlement, /security has the invoke ordering against the real code rather than a description of it, and mcp authorization is the companion piece on the read side this post deliberately does not repeat.

Frequently asked questions

What does mcp tool action approval actually look like from an agent's side?

The agent calls action_preview first to see the exact arguments as they will be sent and whether the run will be held. It then calls action_invoke. If the reply says pending_approval, the effect has not happened, and the correct response is to tell the person that and stop, not to retry the call hoping it goes through, and not to look for a way to approve it, because approving is deliberately not something the agent's own tool list can do.

Why claim the log row before checking entitlement, instead of after?

Because claiming first is what makes 'every invocation that was refused is still logged' true without a qualifier. If the log write happened after entitlement passed, a refused attempt, which is exactly the kind of attempt worth having a record of, would never be written at all.

What is the difference between args_sealed and args_redacted?

args_redacted is what a person reviewing a pending approval sees: a copy with anything named like a secret (config, token, apiKey) replaced with the literal string "[redacted]", so a screen showing pending approvals never displays a credential. args_sealed is the actual arguments as sent, encrypted separately, and it is the only copy a held action is ever executed from once approved. Executing from the redacted copy by mistake once wrote the literal word "[redacted]" into a customer's system with a person's approval attached, because nothing failed loudly when that distinction was crossed.

Can an agent approve its own held action?

No. Discover, approve and reject are deliberately not exposed as tools, specifically so an agent holding an API key can never find its own approval bypass while listing what it can call. Approval happens through a separate surface a person uses, and the approver's own entitlement is re-checked at that point, not inherited from whoever originally requested the action.

Free for 500 retrievals a month, and self-hostable with no limits.